Compare commits

...

9 Commits

48 changed files with 2628 additions and 1043 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -28,4 +28,21 @@ public class StorageServiceTests
var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L }; var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L };
Assert.Equal(2000L, node.VersionSizeBytes); Assert.Equal(2000L, node.VersionSizeBytes);
} }
[Fact]
public void StorageNode_DefaultKind_IsLibrary()
{
var node = new StorageNode();
Assert.Equal(StorageNodeKind.Library, node.Kind);
}
[Fact]
public void StorageScanOptions_DefaultIncludeFlags_AreAllTrue()
{
var opts = new StorageScanOptions();
Assert.True(opts.IncludeHiddenLibraries);
Assert.True(opts.IncludePreservationHold);
Assert.True(opts.IncludeListAttachments);
Assert.True(opts.IncludeRecycleBin);
}
} }
@@ -0,0 +1,141 @@
using System.Reflection;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs;
using Xunit;
namespace SharepointToolbox.Tests.ViewModels;
/// <summary>
/// Verifies that the report filter flags (Show*) project the raw scan output
/// (<c>_allNodes</c>) into <c>Results</c> correctly: hiding a kind drops the
/// matching root nodes plus their entire subtree, preserving DFS ordering.
/// </summary>
public class StorageViewModelFilterTests
{
public StorageViewModelFilterTests() => WeakReferenceMessenger.Default.Reset();
private static StorageViewModel CreateVm()
{
var vm = new StorageViewModel(
new Mock<IStorageService>().Object,
new Mock<ISessionManager>().Object,
NullLogger<FeatureViewModelBase>.Instance);
vm.SetCurrentProfile(new TenantProfile { Name = "T", TenantUrl = "https://t", ClientId = "c" });
return vm;
}
/// <summary>Inject a flat node list straight into the private _allNodes field
/// and trigger a rebuild via toggling a Show flag.</summary>
private static void Seed(StorageViewModel vm, List<StorageNode> flat)
{
var field = typeof(StorageViewModel).GetField("_allNodes",
BindingFlags.Instance | BindingFlags.NonPublic)!;
field.SetValue(vm, flat);
// Toggle off+on to force RebuildFilteredResults().
vm.ShowLibraries = false;
vm.ShowLibraries = true;
}
private static List<StorageNode> MakeMixedTree() => new()
{
new() { Name = "Documents", Kind = StorageNodeKind.Library, IndentLevel = 0, TotalSizeBytes = 100 },
new() { Name = "Sub", Kind = StorageNodeKind.Library, IndentLevel = 1, TotalSizeBytes = 50 },
new() { Name = "Preserve", Kind = StorageNodeKind.PreservationHold, IndentLevel = 0, TotalSizeBytes = 200 },
new() { Name = "[Recycle]", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0, TotalSizeBytes = 300 },
new() { Name = "[Attach] L",Kind = StorageNodeKind.ListAttachments, IndentLevel = 0, TotalSizeBytes = 75 },
};
[Fact]
public void AllShowFlagsTrue_AllNodesAppear()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
Assert.Equal(5, vm.Results.Count);
}
[Fact]
public void HideRecycleBin_RemovesOnlyRecycleNode()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowRecycleBin = false;
Assert.DoesNotContain(vm.Results, n => n.Kind == StorageNodeKind.RecycleBin);
Assert.Equal(4, vm.Results.Count);
}
[Fact]
public void HidePreservationHold_RemovesPreservationNode()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowPreservationHold = false;
Assert.DoesNotContain(vm.Results, n => n.Kind == StorageNodeKind.PreservationHold);
}
[Fact]
public void HideLibraries_DropsLibraryRootAndItsChildren()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowLibraries = false;
// "Documents" + "Sub" both gone — Sub's subtree dropped with its parent root.
Assert.DoesNotContain(vm.Results, n => n.Name == "Documents");
Assert.DoesNotContain(vm.Results, n => n.Name == "Sub");
}
[Fact]
public void CombineRecycleBinStages_True_MergesStagesIntoSingleRow()
{
var vm = CreateVm();
var nodes = new List<StorageNode>
{
new() { Name = "[Recycle Bin] First-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 100, FileStreamSizeBytes = 100, TotalFileCount = 3 },
new() { Name = "[Recycle Bin] Second-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 250, FileStreamSizeBytes = 250, TotalFileCount = 7 },
};
Seed(vm, nodes);
vm.CombineRecycleBinStages = true;
var bins = vm.Results.Where(n => n.Kind == StorageNodeKind.RecycleBin).ToList();
Assert.Single(bins);
Assert.Equal(350, bins[0].TotalSizeBytes);
Assert.Equal(10, bins[0].TotalFileCount);
}
[Fact]
public void CombineRecycleBinStages_False_KeepsSeparateRows()
{
var vm = CreateVm();
var nodes = new List<StorageNode>
{
new() { Name = "[Recycle Bin] First-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 100 },
new() { Name = "[Recycle Bin] Second-stage", Kind = StorageNodeKind.RecycleBin, IndentLevel = 0,
SiteTitle = "S1", TotalSizeBytes = 250 },
};
Seed(vm, nodes);
vm.CombineRecycleBinStages = false;
Assert.Equal(2, vm.Results.Count(n => n.Kind == StorageNodeKind.RecycleBin));
}
[Fact]
public void HideAll_LeavesEmptyResults()
{
var vm = CreateVm();
Seed(vm, MakeMixedTree());
vm.ShowLibraries = false;
vm.ShowHiddenLibraries = false;
vm.ShowPreservationHold = false;
vm.ShowListAttachments = false;
vm.ShowRecycleBin = false;
vm.ShowSubsites = false;
Assert.Empty(vm.Results);
}
}
+1
View File
@@ -16,6 +16,7 @@
<conv:EnumBoolConverter x:Key="EnumBoolConverter" /> <conv:EnumBoolConverter x:Key="EnumBoolConverter" />
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" /> <conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
<conv:ListToStringConverter x:Key="ListToStringConverter" /> <conv:ListToStringConverter x:Key="ListToStringConverter" />
<conv:StorageKindConverter x:Key="StorageKindConverter" />
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" /> <conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock"> <Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" /> <Setter Property="HorizontalAlignment" Value="Right" />
+6
View File
@@ -142,6 +142,12 @@ public partial class App : Application
services.AddTransient<DuplicatesViewModel>(); services.AddTransient<DuplicatesViewModel>();
services.AddTransient<DuplicatesView>(); services.AddTransient<DuplicatesView>();
// Versions cleanup
services.AddTransient<IVersionCleanupService, VersionCleanupService>();
services.AddTransient<VersionCleanupHtmlExportService>();
services.AddTransient<VersionCleanupViewModel>();
services.AddTransient<VersionCleanupView>();
// Phase 2: Permissions // Phase 2: Permissions
services.AddTransient<IPermissionsService, PermissionsService>(); services.AddTransient<IPermissionsService, PermissionsService>();
services.AddTransient<ISiteListService, SiteListService>(); services.AddTransient<ISiteListService, SiteListService>();
@@ -12,5 +12,6 @@ public class StorageNode
public long TotalFileCount { get; set; } public long TotalFileCount { get; set; }
public DateTime? LastModified { get; set; } public DateTime? LastModified { get; set; }
public int IndentLevel { get; set; } public int IndentLevel { get; set; }
public StorageNodeKind Kind { get; set; } = StorageNodeKind.Library;
public List<StorageNode> Children { get; set; } = new(); public List<StorageNode> Children { get; set; } = new();
} }
@@ -0,0 +1,15 @@
namespace SharepointToolbox.Core.Models;
/// <summary>
/// Classification used to filter storage report output. Every node is captured
/// during a scan; the report user picks which categories appear.
/// </summary>
public enum StorageNodeKind
{
Library,
HiddenLibrary,
PreservationHold,
ListAttachments,
RecycleBin,
Subsite
}
@@ -3,5 +3,9 @@ namespace SharepointToolbox.Core.Models;
public record StorageScanOptions( public record StorageScanOptions(
bool PerLibrary = true, bool PerLibrary = true,
bool IncludeSubsites = false, bool IncludeSubsites = false,
int FolderDepth = 0 // 0 = library root only; >0 = recurse N levels int FolderDepth = 0, // 0 = library root only; >0 = recurse N levels
bool IncludeHiddenLibraries = true,
bool IncludePreservationHold = true,
bool IncludeListAttachments = true,
bool IncludeRecycleBin = true
); );
@@ -0,0 +1,9 @@
namespace SharepointToolbox.Core.Models;
public record VersionCleanupOptions(
IReadOnlyList<string> LibraryTitles,
int KeepLast,
bool KeepFirst)
{
public static VersionCleanupOptions Default => new(Array.Empty<string>(), 5, false);
}
@@ -0,0 +1,14 @@
namespace SharepointToolbox.Core.Models;
public class VersionCleanupResult
{
public string SiteUrl { get; init; } = string.Empty;
public string Library { get; init; } = string.Empty;
public string FileServerRelativeUrl { get; init; } = string.Empty;
public string FileName { get; init; } = string.Empty;
public int VersionsBefore { get; init; }
public int VersionsDeleted { get; init; }
public int VersionsRemaining { get; init; }
public long BytesFreed { get; init; }
public string? Error { get; init; }
}
@@ -45,14 +45,13 @@ public class GraphClientFactory
{ {
var pca = await _msalFactory.GetOrCreateAsync(clientId); var pca = await _msalFactory.GetOrCreateAsync(clientId);
// When a tenant is specified we must NOT reuse cached accounts from /common // Always reuse a cached account when one exists — `WithTenantId` on the
// (or a different tenant) — they route tokens to the wrong authority. // silent/interactive call redirects the authority, and MSAL stores
IAccount? account = null; // refresh tokens per tenant. Skipping the cached account forces an
if (tenantId is null) // interactive prompt on every Graph call (the bug that produced 45
{ // sign-in windows during app registration).
var accounts = await pca.GetAccountsAsync(); var accounts = await pca.GetAccountsAsync();
account = accounts.FirstOrDefault(); var account = accounts.FirstOrDefault();
}
var graphScopes = scopes ?? new[] { "https://graph.microsoft.com/.default" }; var graphScopes = scopes ?? new[] { "https://graph.microsoft.com/.default" };
@@ -68,7 +67,7 @@ public class GraphClientFactory
internal class MsalTokenProvider : IAccessTokenProvider internal class MsalTokenProvider : IAccessTokenProvider
{ {
private readonly IPublicClientApplication _pca; private readonly IPublicClientApplication _pca;
private readonly IAccount? _account; private IAccount? _account;
private readonly string[] _scopes; private readonly string[] _scopes;
private readonly string? _tenantId; private readonly string? _tenantId;
@@ -87,19 +86,35 @@ internal class MsalTokenProvider : IAccessTokenProvider
Dictionary<string, object>? additionalAuthenticationContext = null, Dictionary<string, object>? additionalAuthenticationContext = null,
CancellationToken cancellationToken = default) 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); var accounts = await _pca.GetAccountsAsync();
if (_tenantId is not null) silent = silent.WithTenantId(_tenantId); _account = accounts.FirstOrDefault();
var result = await silent.ExecuteAsync(cancellationToken);
return result.AccessToken;
} }
catch (MsalUiRequiredException)
if (_account is not null)
{ {
var interactive = _pca.AcquireTokenInteractive(_scopes); try
if (_tenantId is not null) interactive = interactive.WithTenantId(_tenantId); {
var result = await interactive.ExecuteAsync(cancellationToken); var silent = _pca.AcquireTokenSilent(_scopes, _account);
return result.AccessToken; 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;
} }
} }
+133 -16
View File
@@ -82,6 +82,103 @@
<data name="tab.duplicates" xml:space="preserve"> <data name="tab.duplicates" xml:space="preserve">
<value>Doublons</value> <value>Doublons</value>
</data> </data>
<data name="tab.versions" xml:space="preserve">
<value>Versions</value>
</data>
<data name="versions.tab" xml:space="preserve">
<value>Nettoyage des versions</value>
</data>
<data name="versions.grp.libs" xml:space="preserve">
<value>Bibliothèques</value>
</data>
<data name="versions.grp.policy" xml:space="preserve">
<value>Politique de conservation</value>
</data>
<data name="versions.btn.pickLibs" xml:space="preserve">
<value>Choisir des bibliothèques…</value>
</data>
<data name="versions.btn.clearLibs" xml:space="preserve">
<value>Réinitialiser (toutes les bibliothèques)</value>
</data>
<data name="versions.btn.run" xml:space="preserve">
<value>Supprimer les anciennes versions</value>
</data>
<data name="versions.lbl.keepLast" xml:space="preserve">
<value>Conserver les dernières&#160;:</value>
</data>
<data name="versions.chk.keepFirst" xml:space="preserve">
<value>Conserver aussi la toute première version</value>
</data>
<data name="versions.chk.confirm" xml:space="preserve">
<value>Demander confirmation avant l'exécution</value>
</data>
<data name="versions.note" xml:space="preserve">
<value>Seules les versions historiques sont supprimées. La version courante publiée est toujours conservée. L'action est irréversible.</value>
</data>
<data name="versions.libs.all" xml:space="preserve">
<value>Toutes les bibliothèques (aucun filtre)</value>
</data>
<data name="versions.libs.count" xml:space="preserve">
<value>{0} bibliothèque(s) sélectionnée(s)</value>
</data>
<data name="versions.confirm" xml:space="preserve">
<value>Supprimer les versions historiques en gardant les {0} dernières {1}&#160;?
Cette action est irréversible.</value>
</data>
<data name="versions.confirm.keepFirst" xml:space="preserve">
<value>(plus la première version)</value>
</data>
<data name="versions.err.keepLast" xml:space="preserve">
<value>«&#160;Conserver les dernières&#160;» doit être supérieur ou égal à 0.</value>
</data>
<data name="versions.summary.files" xml:space="preserve">
<value>Fichiers nettoyés&#160;:</value>
</data>
<data name="versions.summary.deleted" xml:space="preserve">
<value>Versions supprimées&#160;:</value>
</data>
<data name="versions.summary.freed" xml:space="preserve">
<value>Octets libérés&#160;:</value>
</data>
<data name="versions.col.library" xml:space="preserve">
<value>Bibliothèque</value>
</data>
<data name="versions.col.file" xml:space="preserve">
<value>Fichier</value>
</data>
<data name="versions.col.before" xml:space="preserve">
<value>Avant</value>
</data>
<data name="versions.col.deleted" xml:space="preserve">
<value>Supprimées</value>
</data>
<data name="versions.col.remaining" xml:space="preserve">
<value>Restantes</value>
</data>
<data name="versions.col.freed" xml:space="preserve">
<value>Libérés</value>
</data>
<data name="versions.col.path" xml:space="preserve">
<value>Chemin</value>
</data>
<data name="versions.col.error" xml:space="preserve">
<value>Erreur</value>
</data>
<data name="librarypicker.title" xml:space="preserve">
<value>Sélectionner les bibliothèques</value>
</data>
<data name="librarypicker.loading" xml:space="preserve">
<value>Chargement des bibliothèques…</value>
</data>
<data name="librarypicker.loaded" xml:space="preserve">
<value>{0} bibliothèques chargées.</value>
</data>
<data name="librarypicker.selectAll" xml:space="preserve">
<value>Tout sélectionner</value>
</data>
<data name="librarypicker.selectNone" xml:space="preserve">
<value>Tout désélectionner</value>
</data>
<data name="tab.templates" xml:space="preserve"> <data name="tab.templates" xml:space="preserve">
<value>Modèles</value> <value>Modèles</value>
</data> </data>
@@ -148,6 +245,18 @@
<data name="profile.delete" xml:space="preserve"> <data name="profile.delete" xml:space="preserve">
<value>Supprimer</value> <value>Supprimer</value>
</data> </data>
<data name="profile.add.tooltip" xml:space="preserve">
<value>Créer un nouveau profil à partir des valeurs ci-dessus.</value>
</data>
<data name="profile.save.tooltip" xml:space="preserve">
<value>Enregistrer les modifications du profil sélectionné.</value>
</data>
<data name="profile.delete.tooltip" xml:space="preserve">
<value>Supprimer le profil sélectionné.</value>
</data>
<data name="profile.register.warning" xml:space="preserve">
<value>L'enregistrement de l'application peut nécessiter jusqu'à {0} connexions. Continuer&#160;?</value>
</data>
<data name="status.ready" xml:space="preserve"> <data name="status.ready" xml:space="preserve">
<value>Prêt</value> <value>Prêt</value>
</data> </data>
@@ -197,6 +306,28 @@
<data name="stor.col.share" xml:space="preserve"><value>Part du total</value></data> <data name="stor.col.share" xml:space="preserve"><value>Part du total</value></data>
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data> <data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data> <data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
<data name="stor.col.kind" xml:space="preserve"><value>Type</value></data>
<data name="stor.kind.library" xml:space="preserve"><value>Bibliothèque</value></data>
<data name="stor.kind.hidden" xml:space="preserve"><value>Bibliothèque masquée</value></data>
<data name="stor.kind.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="stor.kind.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="stor.kind.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="stor.kind.subsite" xml:space="preserve"><value>Sous-site</value></data>
<data name="grp.scan.sources" xml:space="preserve"><value>Sources analysées</value></data>
<data name="grp.report.filter" xml:space="preserve"><value>Afficher dans le rapport</value></data>
<data name="chk.scan.hidden" xml:space="preserve"><value>Bibliothèques masquées</value></data>
<data name="chk.scan.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="chk.scan.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="chk.scan.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="chk.show.libraries" xml:space="preserve"><value>Bibliothèques</value></data>
<data name="chk.show.hidden" xml:space="preserve"><value>Bibliothèques masquées</value></data>
<data name="chk.show.preservation" xml:space="preserve"><value>Conservation</value></data>
<data name="chk.show.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
<data name="chk.show.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
<data name="chk.show.subsites" xml:space="preserve"><value>Sous-sites</value></data>
<data name="chk.combine.recyclebin" xml:space="preserve"><value>Combiner les corbeilles (afficher le total)</value></data>
<data name="storage.lbl.spo_reported_colon" xml:space="preserve"><value>Total rapporté par SPO : </value></data>
<data name="storage.lbl.recyclebin_colon" xml:space="preserve"><value>Corbeille : </value></data>
<!-- Phase 3: File Search Tab --> <!-- Phase 3: File Search Tab -->
<data name="grp.search.filters" xml:space="preserve"><value>Filtres de recherche</value></data> <data name="grp.search.filters" xml:space="preserve"><value>Filtres de recherche</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Niveau de d&#233;tail :</value></data> <data name="lbl.detail.level" xml:space="preserve"><value>Niveau de d&#233;tail :</value></data>
@@ -483,6 +614,8 @@
<data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de d&#233;tection de doublons</value></data> <data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de d&#233;tection de doublons</value></data>
<data name="report.title.search" xml:space="preserve"><value>R&#233;sultats de recherche de fichiers SharePoint</value></data> <data name="report.title.search" xml:space="preserve"><value>R&#233;sultats de recherche de fichiers SharePoint</value></data>
<data name="report.title.search_short" xml:space="preserve"><value>R&#233;sultats de recherche de fichiers</value></data> <data name="report.title.search_short" xml:space="preserve"><value>R&#233;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&#232;s totaux</value></data> <data name="report.stat.total_accesses" xml:space="preserve"><value>Acc&#232;s totaux</value></data>
<data name="report.stat.users_audited" xml:space="preserve"><value>Utilisateurs audit&#233;s</value></data> <data name="report.stat.users_audited" xml:space="preserve"><value>Utilisateurs audit&#233;s</value></data>
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites analys&#233;s</value></data> <data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites analys&#233;s</value></data>
@@ -536,14 +669,7 @@
<data name="report.col.error" xml:space="preserve"><value>Erreur</value></data> <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.timestamp" xml:space="preserve"><value>Horodatage</value></data>
<data name="report.col.number" xml:space="preserve"><value>#</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> <data name="report.col.group" xml:space="preserve"><value>Groupe</value></data>
=======
<<<<<<< HEAD
<data name="report.col.group" xml:space="preserve"><value>Groupe</value></data>
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<data name="report.col.total_size_mb" xml:space="preserve"><value>Taille totale (Mo)</value></data> <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.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> <data name="report.col.size_mb" xml:space="preserve"><value>Taille (Mo)</value></data>
@@ -566,10 +692,6 @@
<data name="report.text.high_priv" xml:space="preserve"><value>priv. &#233;lev&#233;</value></data> <data name="report.text.high_priv" xml:space="preserve"><value>priv. &#233;lev&#233;</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.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&#233;tails des biblioth&#232;ques</value></data> <data name="report.section.library_details" xml:space="preserve"><value>D&#233;tails des biblioth&#232;ques</value></data>
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<!-- Site picker dialog --> <!-- Site picker dialog -->
<data name="sitepicker.title" xml:space="preserve"><value>S&#233;lectionner les sites</value></data> <data name="sitepicker.title" xml:space="preserve"><value>S&#233;lectionner les sites</value></data>
<data name="sitepicker.filter" xml:space="preserve"><value>Filtre&#160;:</value></data> <data name="sitepicker.filter" xml:space="preserve"><value>Filtre&#160;:</value></data>
@@ -661,9 +783,4 @@
<data name="report.text.users_parens" xml:space="preserve"><value>utilisateur(s)</value></data> <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.files_unit" xml:space="preserve"><value>fichiers</value></data>
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data> <data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
<<<<<<< HEAD
=======
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
</root> </root>
+133 -16
View File
@@ -82,6 +82,103 @@
<data name="tab.duplicates" xml:space="preserve"> <data name="tab.duplicates" xml:space="preserve">
<value>Duplicates</value> <value>Duplicates</value>
</data> </data>
<data name="tab.versions" xml:space="preserve">
<value>Versions</value>
</data>
<data name="versions.tab" xml:space="preserve">
<value>Version cleanup</value>
</data>
<data name="versions.grp.libs" xml:space="preserve">
<value>Libraries</value>
</data>
<data name="versions.grp.policy" xml:space="preserve">
<value>Retention policy</value>
</data>
<data name="versions.btn.pickLibs" xml:space="preserve">
<value>Select libraries...</value>
</data>
<data name="versions.btn.clearLibs" xml:space="preserve">
<value>Reset (all libraries)</value>
</data>
<data name="versions.btn.run" xml:space="preserve">
<value>Delete old versions</value>
</data>
<data name="versions.lbl.keepLast" xml:space="preserve">
<value>Keep last:</value>
</data>
<data name="versions.chk.keepFirst" xml:space="preserve">
<value>Also keep the very first version</value>
</data>
<data name="versions.chk.confirm" xml:space="preserve">
<value>Ask for confirmation before running</value>
</data>
<data name="versions.note" xml:space="preserve">
<value>Only historical versions are removed. The current published version is always kept. The action cannot be undone.</value>
</data>
<data name="versions.libs.all" xml:space="preserve">
<value>All libraries (no filter)</value>
</data>
<data name="versions.libs.count" xml:space="preserve">
<value>{0} library/libraries selected</value>
</data>
<data name="versions.confirm" xml:space="preserve">
<value>Delete historical file versions, keeping the last {0} {1}?
This cannot be undone.</value>
</data>
<data name="versions.confirm.keepFirst" xml:space="preserve">
<value>(plus the first version)</value>
</data>
<data name="versions.err.keepLast" xml:space="preserve">
<value>"Keep last" must be 0 or greater.</value>
</data>
<data name="versions.summary.files" xml:space="preserve">
<value>Files trimmed:</value>
</data>
<data name="versions.summary.deleted" xml:space="preserve">
<value>Versions deleted:</value>
</data>
<data name="versions.summary.freed" xml:space="preserve">
<value>Bytes freed:</value>
</data>
<data name="versions.col.library" xml:space="preserve">
<value>Library</value>
</data>
<data name="versions.col.file" xml:space="preserve">
<value>File</value>
</data>
<data name="versions.col.before" xml:space="preserve">
<value>Before</value>
</data>
<data name="versions.col.deleted" xml:space="preserve">
<value>Deleted</value>
</data>
<data name="versions.col.remaining" xml:space="preserve">
<value>Remaining</value>
</data>
<data name="versions.col.freed" xml:space="preserve">
<value>Freed</value>
</data>
<data name="versions.col.path" xml:space="preserve">
<value>Path</value>
</data>
<data name="versions.col.error" xml:space="preserve">
<value>Error</value>
</data>
<data name="librarypicker.title" xml:space="preserve">
<value>Select libraries</value>
</data>
<data name="librarypicker.loading" xml:space="preserve">
<value>Loading libraries...</value>
</data>
<data name="librarypicker.loaded" xml:space="preserve">
<value>{0} libraries loaded.</value>
</data>
<data name="librarypicker.selectAll" xml:space="preserve">
<value>Select all</value>
</data>
<data name="librarypicker.selectNone" xml:space="preserve">
<value>Select none</value>
</data>
<data name="tab.templates" xml:space="preserve"> <data name="tab.templates" xml:space="preserve">
<value>Templates</value> <value>Templates</value>
</data> </data>
@@ -148,6 +245,18 @@
<data name="profile.delete" xml:space="preserve"> <data name="profile.delete" xml:space="preserve">
<value>Delete</value> <value>Delete</value>
</data> </data>
<data name="profile.add.tooltip" xml:space="preserve">
<value>Create a new profile from the values entered above.</value>
</data>
<data name="profile.save.tooltip" xml:space="preserve">
<value>Save changes to the selected profile.</value>
</data>
<data name="profile.delete.tooltip" xml:space="preserve">
<value>Delete the selected profile.</value>
</data>
<data name="profile.register.warning" xml:space="preserve">
<value>Registering an app may prompt you to sign in up to {0} times. Continue?</value>
</data>
<data name="status.ready" xml:space="preserve"> <data name="status.ready" xml:space="preserve">
<value>Ready</value> <value>Ready</value>
</data> </data>
@@ -197,6 +306,28 @@
<data name="stor.col.share" xml:space="preserve"><value>Share of Total</value></data> <data name="stor.col.share" xml:space="preserve"><value>Share of Total</value></data>
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data> <data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data> <data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
<data name="stor.col.kind" xml:space="preserve"><value>Kind</value></data>
<data name="stor.kind.library" xml:space="preserve"><value>Library</value></data>
<data name="stor.kind.hidden" xml:space="preserve"><value>Hidden Library</value></data>
<data name="stor.kind.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="stor.kind.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="stor.kind.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="stor.kind.subsite" xml:space="preserve"><value>Subsite</value></data>
<data name="grp.scan.sources" xml:space="preserve"><value>Scan Sources</value></data>
<data name="grp.report.filter" xml:space="preserve"><value>Show in Report</value></data>
<data name="chk.scan.hidden" xml:space="preserve"><value>Hidden Libraries</value></data>
<data name="chk.scan.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="chk.scan.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="chk.scan.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="chk.show.libraries" xml:space="preserve"><value>Libraries</value></data>
<data name="chk.show.hidden" xml:space="preserve"><value>Hidden Libraries</value></data>
<data name="chk.show.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
<data name="chk.show.attachments" xml:space="preserve"><value>List Attachments</value></data>
<data name="chk.show.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
<data name="chk.show.subsites" xml:space="preserve"><value>Subsites</value></data>
<data name="chk.combine.recyclebin" xml:space="preserve"><value>Combine Recycle Bin Stages (show total)</value></data>
<data name="storage.lbl.spo_reported_colon" xml:space="preserve"><value>SPO reported total: </value></data>
<data name="storage.lbl.recyclebin_colon" xml:space="preserve"><value>Recycle Bin: </value></data>
<!-- Phase 3: File Search Tab --> <!-- Phase 3: File Search Tab -->
<data name="grp.search.filters" xml:space="preserve"><value>Search Filters</value></data> <data name="grp.search.filters" xml:space="preserve"><value>Search Filters</value></data>
<data name="lbl.detail.level" xml:space="preserve"><value>Detail level:</value></data> <data name="lbl.detail.level" xml:space="preserve"><value>Detail level:</value></data>
@@ -483,6 +614,8 @@
<data name="report.title.duplicates_short" xml:space="preserve"><value>Duplicate Detection Report</value></data> <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" 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.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.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.users_audited" xml:space="preserve"><value>Users Audited</value></data>
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites Scanned</value></data> <data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites Scanned</value></data>
@@ -536,14 +669,7 @@
<data name="report.col.error" xml:space="preserve"><value>Error</value></data> <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.timestamp" xml:space="preserve"><value>Timestamp</value></data>
<data name="report.col.number" xml:space="preserve"><value>#</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> <data name="report.col.group" xml:space="preserve"><value>Group</value></data>
=======
<<<<<<< HEAD
<data name="report.col.group" xml:space="preserve"><value>Group</value></data>
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<data name="report.col.total_size_mb" xml:space="preserve"><value>Total Size (MB)</value></data> <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.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> <data name="report.col.size_mb" xml:space="preserve"><value>Size (MB)</value></data>
@@ -566,10 +692,6 @@
<data name="report.text.high_priv" xml:space="preserve"><value>high-priv</value></data> <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.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> <data name="report.section.library_details" xml:space="preserve"><value>Library Details</value></data>
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<!-- Site picker dialog --> <!-- Site picker dialog -->
<data name="sitepicker.title" xml:space="preserve"><value>Select Sites</value></data> <data name="sitepicker.title" xml:space="preserve"><value>Select Sites</value></data>
<data name="sitepicker.filter" xml:space="preserve"><value>Filter:</value></data> <data name="sitepicker.filter" xml:space="preserve"><value>Filter:</value></data>
@@ -661,9 +783,4 @@
<data name="report.text.users_parens" xml:space="preserve"><value>user(s)</value></data> <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.files_unit" xml:space="preserve"><value>files</value></data>
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data> <data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
<<<<<<< HEAD
=======
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
</root> </root>
+4 -2
View File
@@ -8,6 +8,7 @@
xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs" xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs"
mc:Ignorable="d" mc:Ignorable="d"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}"
Icon="pack://application:,,,/Resources/SPToolbox-logo-ico.png"
Background="{DynamicResource AppBgBrush}" Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}" Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal" TextOptions.TextFormattingMode="Ideal"
@@ -18,8 +19,6 @@
<ComboBox Width="220" ItemsSource="{Binding TenantProfiles}" <ComboBox Width="220" ItemsSource="{Binding TenantProfiles}"
SelectedItem="{Binding SelectedProfile}" SelectedItem="{Binding SelectedProfile}"
DisplayMemberPath="Name" /> DisplayMemberPath="Name" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.connect]}"
Command="{Binding ConnectCommand}" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.manage]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.manage]}"
Command="{Binding ManageProfilesCommand}" /> Command="{Binding ManageProfilesCommand}" />
<Separator /> <Separator />
@@ -63,6 +62,9 @@
<TabItem x:Name="DuplicatesTabItem" <TabItem x:Name="DuplicatesTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}"> Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.duplicates]}">
</TabItem> </TabItem>
<TabItem x:Name="VersionsTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.versions]}">
</TabItem>
<TabItem x:Name="TransferTabItem" <TabItem x:Name="TransferTabItem"
Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.transfer]}"> Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.transfer]}">
</TabItem> </TabItem>
+3
View File
@@ -40,6 +40,9 @@ public partial class MainWindow : Window
// Replace Duplicates tab placeholder with the DI-resolved DuplicatesView // Replace Duplicates tab placeholder with the DI-resolved DuplicatesView
DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>(); DuplicatesTabItem.Content = serviceProvider.GetRequiredService<DuplicatesView>();
// Versions cleanup tab
VersionsTabItem.Content = serviceProvider.GetRequiredService<VersionCleanupView>();
// Phase 4: Replace stub tabs with DI-resolved Views // Phase 4: Replace stub tabs with DI-resolved Views
TransferTabItem.Content = serviceProvider.GetRequiredService<TransferView>(); TransferTabItem.Content = serviceProvider.GetRequiredService<TransferView>();
BulkMembersTabItem.Content = serviceProvider.GetRequiredService<BulkMembersView>(); BulkMembersTabItem.Content = serviceProvider.GetRequiredService<BulkMembersView>();
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@@ -12,23 +12,12 @@ namespace SharepointToolbox.Services.Export;
/// </summary> /// </summary>
public class DuplicatesCsvExportService public class DuplicatesCsvExportService
{ {
<<<<<<< HEAD
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary> /// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary>
=======
<<<<<<< HEAD
/// <summary>Writes the CSV to <paramref name="filePath"/> with UTF-8 BOM (Excel-compatible).</summary>
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
public async Task WriteAsync( public async Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups, IReadOnlyList<DuplicateGroup> groups,
string filePath, string filePath,
CancellationToken ct) CancellationToken ct)
{ {
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
var csv = BuildCsv(groups); var csv = BuildCsv(groups);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); await ExportFileWriter.WriteCsvAsync(filePath, csv, ct);
} }
@@ -70,11 +59,6 @@ public class DuplicatesCsvExportService
/// </summary> /// </summary>
public string BuildCsv(IReadOnlyList<DuplicateGroup> groups) public string BuildCsv(IReadOnlyList<DuplicateGroup> groups)
{ {
<<<<<<< HEAD
=======
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -88,20 +72,9 @@ public class DuplicatesCsvExportService
sb.AppendLine(string.Join(",", new[] sb.AppendLine(string.Join(",", new[]
{ {
Csv(T["report.col.number"]), Csv(T["report.col.number"]),
<<<<<<< HEAD
Csv(T["report.col.group"]), Csv(T["report.col.group"]),
Csv(T["report.text.copies"]), Csv(T["report.text.copies"]),
Csv(T["report.col.site"]), Csv(T["report.col.site"]),
=======
<<<<<<< HEAD
Csv(T["report.col.group"]),
Csv(T["report.text.copies"]),
Csv(T["report.col.site"]),
=======
Csv("Group"),
Csv(T["report.text.copies"]),
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Csv(T["report.col.name"]), Csv(T["report.col.name"]),
Csv(T["report.col.library"]), Csv(T["report.col.library"]),
Csv(T["report.col.path"]), Csv(T["report.col.path"]),
@@ -110,13 +83,6 @@ public class DuplicatesCsvExportService
Csv(T["report.col.modified"]), Csv(T["report.col.modified"]),
})); }));
<<<<<<< HEAD
=======
<<<<<<< HEAD
=======
// Rows
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
foreach (var g in groups) foreach (var g in groups)
{ {
int i = 0; int i = 0;
@@ -128,14 +94,7 @@ public class DuplicatesCsvExportService
Csv(i.ToString()), Csv(i.ToString()),
Csv(g.Name), Csv(g.Name),
Csv(g.Items.Count.ToString()), Csv(g.Items.Count.ToString()),
<<<<<<< HEAD
Csv(item.SiteTitle), Csv(item.SiteTitle),
=======
<<<<<<< HEAD
Csv(item.SiteTitle),
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Csv(item.Name), Csv(item.Name),
Csv(item.Library), Csv(item.Library),
Csv(item.Path), Csv(item.Path),
@@ -146,26 +105,8 @@ public class DuplicatesCsvExportService
} }
} }
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
return sb.ToString(); return sb.ToString();
} }
private static string Csv(string value) => CsvSanitizer.Escape(value); private static string Csv(string value) => CsvSanitizer.Escape(value);
<<<<<<< HEAD
=======
=======
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
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
} }
@@ -24,4 +24,41 @@ internal static class ExportFileWriter
/// <summary>Writes <paramref name="html"/> to <paramref name="filePath"/> as UTF-8 without BOM.</summary> /// <summary>Writes <paramref name="html"/> to <paramref name="filePath"/> as UTF-8 without BOM.</summary>
public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct) public static Task WriteHtmlAsync(string filePath, string html, CancellationToken ct)
=> File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct); => File.WriteAllTextAsync(filePath, html, Utf8NoBom, ct);
/// <summary>
/// Streams a <see cref="StringBuilder"/> directly to disk as UTF-8 with
/// BOM, chunk by chunk. Avoids the full-document <c>ToString()</c> copy
/// and the separate UTF-8 byte buffer that <see cref="File.WriteAllTextAsync(string, string, Encoding, CancellationToken)"/>
/// would otherwise allocate — meaningful for large CSV exports.
/// </summary>
public static Task WriteCsvChunksAsync(string filePath, StringBuilder builder, CancellationToken ct)
=> WriteChunksAsync(filePath, builder, Utf8WithBom, ct);
/// <summary>
/// Streams a <see cref="StringBuilder"/> directly to disk as UTF-8 without
/// BOM. Same rationale as <see cref="WriteCsvChunksAsync"/> — for large
/// HTML reports it halves peak memory by skipping the intermediate string.
/// </summary>
public static Task WriteHtmlChunksAsync(string filePath, StringBuilder builder, CancellationToken ct)
=> WriteChunksAsync(filePath, builder, Utf8NoBom, ct);
private static async Task WriteChunksAsync(string filePath, StringBuilder builder, Encoding encoding, CancellationToken ct)
{
// FileOptions.Asynchronous lets StreamWriter use true async I/O.
await using var fs = new FileStream(
filePath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 64 * 1024,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
await using var sw = new StreamWriter(fs, encoding, bufferSize: 64 * 1024);
foreach (var chunk in builder.GetChunks())
{
ct.ThrowIfCancellationRequested();
await sw.WriteAsync(chunk, ct);
}
await sw.FlushAsync(ct);
}
} }
@@ -2,14 +2,7 @@ using System.IO;
using System.Text; using System.Text;
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization; using SharepointToolbox.Localization;
<<<<<<< HEAD
using static SharepointToolbox.Services.Export.PermissionHtmlFragments; using static SharepointToolbox.Services.Export.PermissionHtmlFragments;
=======
<<<<<<< HEAD
using static SharepointToolbox.Services.Export.PermissionHtmlFragments;
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
namespace SharepointToolbox.Services.Export; namespace SharepointToolbox.Services.Export;
@@ -37,10 +30,6 @@ public class HtmlExportService
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null) IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats( var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
entries.Count, entries.Count,
entries.Select(e => e.PermissionLevels), entries.Select(e => e.PermissionLevels),
@@ -54,87 +43,6 @@ public class HtmlExportService
AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers); AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers);
AppendFilterInput(sb); AppendFilterInput(sb);
AppendTableOpen(sb); AppendTableOpen(sb);
<<<<<<< HEAD
=======
=======
// 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
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
sb.AppendLine("<thead><tr>"); 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($" <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>"); sb.AppendLine("</tr></thead>");
@@ -147,58 +55,9 @@ a:hover { text-decoration: underline; }
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited"; var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"]; var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
<<<<<<< HEAD
var (pills, subRows) = BuildUserPillsCell( var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers, entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
colSpan: 7, grpMemIdx: ref grpMemIdx); colSpan: 7, grpMemIdx: ref grpMemIdx);
=======
<<<<<<< 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)} &#9660;</span>");
string memberContent;
if (resolvedMembers.Count > 0)
{
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;");
memberContent = string.Join(" &bull; ", 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
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
sb.AppendLine("<tr>"); sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>"); sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
@@ -432,235 +291,4 @@ a:hover { text-decoration: underline; }
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"), RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
_ => ("#F3F4F6", "#374151", "#E5E7EB") _ => ("#F3F4F6", "#374151", "#E5E7EB")
}; };
<<<<<<< HEAD
=======
<<<<<<< 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)} &#9660;</span>");
string memberContent;
if (resolvedMembers.Count > 0)
{
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;");
memberContent = string.Join(" &bull; ", 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("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
} }
@@ -1,5 +1,6 @@
using SharepointToolbox.Core.Models; using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization; using SharepointToolbox.Localization;
using System.Globalization;
using System.IO; using System.IO;
using System.Text; using System.Text;
@@ -18,33 +19,58 @@ public class StorageCsvExportService
/// </summary> /// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes) public string BuildCsv(IReadOnlyList<StorageNode> nodes)
{ {
var T = TranslationSource.Instance; // Pre-size: ~110 chars/row + header avoids most StringBuilder growth.
var sb = new StringBuilder(); var sb = new StringBuilder(128 + nodes.Count * 110);
WriteCsv(sb, nodes);
return sb.ToString();
}
// Header private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes)
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}"); {
var T = TranslationSource.Instance;
// Hoist resource lookups out of the row loop: ResourceManager.GetString
// is a culture-aware dictionary probe — caching once per export saves
// O(rows × columns) lookups on large tenants.
string colLibrary = T["report.col.library"];
string colKind = T["stor.col.kind"];
string colSite = T["report.col.site"];
string colFiles = T["report.stat.files"];
string colTotalMb = T["report.col.total_size_mb"];
string colVerMb = T["report.col.version_size_mb"];
string colLastMod = T["report.col.last_modified"];
sb.Append(colLibrary).Append(',')
.Append(colKind).Append(',')
.Append(colSite).Append(',')
.Append(colFiles).Append(',')
.Append(colTotalMb).Append(',')
.Append(colVerMb).Append(',')
.AppendLine(colLastMod);
var kindLabels = BuildKindLabelCache();
foreach (var node in nodes) foreach (var node in nodes)
{ {
sb.AppendLine(string.Join(",", AppendCsvField(sb, node.Name).Append(',');
Csv(node.Name), AppendCsvField(sb, kindLabels[(int)node.Kind]).Append(',');
Csv(node.SiteTitle), AppendCsvField(sb, node.SiteTitle).Append(',');
node.TotalFileCount.ToString(), sb.Append(node.TotalFileCount).Append(',');
FormatMb(node.TotalSizeBytes), AppendMb(sb, node.TotalSizeBytes).Append(',');
FormatMb(node.VersionSizeBytes), AppendMb(sb, node.VersionSizeBytes).Append(',');
node.LastModified.HasValue if (node.LastModified.HasValue)
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd")) AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
: string.Empty)); sb.AppendLine();
} }
return sb.ToString();
} }
/// <summary>Writes the library-level CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary> /// <summary>Writes the library-level CSV to <paramref name="filePath"/> with UTF-8 BOM.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct) public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
{ {
var csv = BuildCsv(nodes); // Stream straight to disk: skip the StringBuilder→string copy and the
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); // separate UTF-8 buffer that File.WriteAllTextAsync materializes.
var sb = new StringBuilder(128 + nodes.Count * 110);
WriteCsv(sb, nodes);
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
} }
/// <summary> /// <summary>
@@ -52,44 +78,68 @@ public class StorageCsvExportService
/// </summary> /// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics) public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{ {
var T = TranslationSource.Instance; var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
var sb = new StringBuilder(); WriteCsv(sb, nodes, fileTypeMetrics);
return sb.ToString();
}
private static void WriteCsv(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{
var T = TranslationSource.Instance;
string colLibrary = T["report.col.library"];
string colSite = T["report.col.site"];
string colFiles = T["report.stat.files"];
string colTotalMb = T["report.col.total_size_mb"];
string colVerMb = T["report.col.version_size_mb"];
string colLastMod = T["report.col.last_modified"];
sb.Append(colLibrary).Append(',')
.Append(colSite).Append(',')
.Append(colFiles).Append(',')
.Append(colTotalMb).Append(',')
.Append(colVerMb).Append(',')
.AppendLine(colLastMod);
// Library details
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
foreach (var node in nodes) foreach (var node in nodes)
{ {
sb.AppendLine(string.Join(",", AppendCsvField(sb, node.Name).Append(',');
Csv(node.Name), AppendCsvField(sb, node.SiteTitle).Append(',');
Csv(node.SiteTitle), sb.Append(node.TotalFileCount).Append(',');
node.TotalFileCount.ToString(), AppendMb(sb, node.TotalSizeBytes).Append(',');
FormatMb(node.TotalSizeBytes), AppendMb(sb, node.VersionSizeBytes).Append(',');
FormatMb(node.VersionSizeBytes), if (node.LastModified.HasValue)
node.LastModified.HasValue AppendCsvField(sb, node.LastModified.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd")) sb.AppendLine();
: string.Empty));
} }
// File type breakdown
if (fileTypeMetrics.Count > 0) if (fileTypeMetrics.Count > 0)
{ {
string colFileType = T["report.col.file_type"];
string colSizeMb = T["report.col.size_mb"];
string colFileCnt = T["report.col.file_count"];
string noExtLabel = T["report.text.no_extension"];
sb.AppendLine(); sb.AppendLine();
sb.AppendLine($"{T["report.col.file_type"]},{T["report.col.size_mb"]},{T["report.col.file_count"]}"); sb.Append(colFileType).Append(',')
.Append(colSizeMb).Append(',')
.AppendLine(colFileCnt);
foreach (var m in fileTypeMetrics) foreach (var m in fileTypeMetrics)
{ {
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_extension"] : m.Extension; string label = string.IsNullOrEmpty(m.Extension) ? noExtLabel : m.Extension;
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString())); AppendCsvField(sb, label).Append(',');
AppendMb(sb, m.TotalSizeBytes).Append(',');
sb.Append(m.FileCount).AppendLine();
} }
} }
return sb.ToString();
} }
/// <summary>Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM.</summary> /// <summary>Writes the two-section CSV (libraries + file-type breakdown) with UTF-8 BOM.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct) public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
{ {
var csv = BuildCsv(nodes, fileTypeMetrics); var sb = new StringBuilder(192 + nodes.Count * 100 + fileTypeMetrics.Count * 40);
await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); WriteCsv(sb, nodes, fileTypeMetrics);
await ExportFileWriter.WriteCsvChunksAsync(filePath, sb, ct);
} }
/// <summary> /// <summary>
@@ -138,9 +188,40 @@ public class StorageCsvExportService
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
private static string FormatMb(long bytes) private static StringBuilder AppendMb(StringBuilder sb, long bytes)
=> (bytes / (1024.0 * 1024.0)).ToString("F2"); => sb.Append((bytes / (1024.0 * 1024.0)).ToString("F2", CultureInfo.InvariantCulture));
/// <summary>RFC 4180 CSV field quoting with formula-injection guard.</summary> private static StringBuilder AppendCsvField(StringBuilder sb, string value)
private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value); => sb.Append(CsvSanitizer.EscapeMinimal(value));
/// <summary>
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
/// once per export, indexed by the enum's int value. Avoids a
/// <c>ResourceManager.GetString</c> call per row in hot CSV loops.
/// </summary>
private static string[] BuildKindLabelCache()
{
var values = (StorageNodeKind[])Enum.GetValues(typeof(StorageNodeKind));
int max = 0;
foreach (var v in values) { int i = (int)v; if (i > max) max = i; }
var cache = new string[max + 1];
for (int i = 0; i < cache.Length; i++) cache[i] = ((StorageNodeKind)i).ToString();
foreach (var v in values) cache[(int)v] = KindLabel(v);
return cache;
}
private static string KindLabel(StorageNodeKind kind)
{
var T = TranslationSource.Instance;
return kind switch
{
StorageNodeKind.Library => T["stor.kind.library"],
StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
} }
@@ -13,6 +13,8 @@ namespace SharepointToolbox.Services.Export;
public class StorageHtmlExportService public class StorageHtmlExportService
{ {
private int _togIdx; private int _togIdx;
private string[] _kindLabels = Array.Empty<string>();
private string[] _kindLabelsHtml = Array.Empty<string>();
/// <summary> /// <summary>
/// Builds a self-contained HTML report with one collapsible row per /// Builds a self-contained HTML report with one collapsible row per
@@ -21,10 +23,18 @@ public class StorageHtmlExportService
/// breakdown section is desired. /// breakdown section is desired.
/// </summary> /// </summary>
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null) public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
{
var sb = new StringBuilder(3072 + nodes.Count * 340);
BuildHtmlCore(sb, nodes, branding);
return sb.ToString();
}
private void BuildHtmlCore(StringBuilder sb, IReadOnlyList<StorageNode> nodes, ReportBranding? branding)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
_togIdx = 0; _togIdx = 0;
var sb = new StringBuilder(); _kindLabels = BuildKindLabelCache();
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
sb.AppendLine("<!DOCTYPE html>"); sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">"); sb.AppendLine("<html lang=\"en\">");
@@ -60,11 +70,18 @@ public class StorageHtmlExportService
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>"); sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// Summary cards // Single-pass root aggregation: replaces 4 separate enumerations
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList(); // (.Where().ToList() + 3× .Sum() + a final .Where() during render).
long siteTotal0 = rootNodes0.Sum(n => n.TotalSizeBytes); var rootNodes0 = new List<StorageNode>(Math.Min(nodes.Count, 64));
long versionTotal0 = rootNodes0.Sum(n => n.VersionSizeBytes); long siteTotal0 = 0, versionTotal0 = 0, fileTotal0 = 0;
long fileTotal0 = rootNodes0.Sum(n => n.TotalFileCount); foreach (var n in nodes)
{
if (n.IndentLevel != 0) continue;
rootNodes0.Add(n);
siteTotal0 += n.TotalSizeBytes;
versionTotal0 += n.VersionSizeBytes;
fileTotal0 += n.TotalFileCount;
}
sb.AppendLine($""" sb.AppendLine($"""
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap"> <div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
@@ -79,6 +96,7 @@ public class StorageHtmlExportService
<thead> <thead>
<tr> <tr>
<th>{T["report.col.library_folder"]}</th> <th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th> <th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th> <th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th> <th class="num">{T["report.stat.total_size"]}</th>
@@ -89,7 +107,10 @@ public class StorageHtmlExportService
<tbody> <tbody>
"""); """);
foreach (var node in nodes) // Render only the pre-materialized root list — recursing into
// Children handles descendants. Iterating the flat list would render
// every descendant a second time as a top-level row.
foreach (var node in rootNodes0)
{ {
RenderNode(sb, node); RenderNode(sb, node);
} }
@@ -101,18 +122,24 @@ public class StorageHtmlExportService
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>"); sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>"); sb.AppendLine("</body></html>");
return sb.ToString();
} }
/// <summary> /// <summary>
/// Builds an HTML report including a file-type breakdown chart section. /// Builds an HTML report including a file-type breakdown chart section.
/// </summary> /// </summary>
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null) public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
{
var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
return sb.ToString();
}
private void BuildHtmlCore(StringBuilder sb, IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding)
{ {
var T = TranslationSource.Instance; var T = TranslationSource.Instance;
_togIdx = 0; _togIdx = 0;
var sb = new StringBuilder(); _kindLabels = BuildKindLabelCache();
_kindLabelsHtml = BuildHtmlEncodedCache(_kindLabels);
sb.AppendLine("<!DOCTYPE html>"); sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">"); sb.AppendLine("<html lang=\"en\">");
@@ -159,11 +186,17 @@ public class StorageHtmlExportService
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>"); sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// ── Summary cards ── // ── Summary cards (single-pass aggregation) ──
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList(); var rootNodes = new List<StorageNode>(Math.Min(nodes.Count, 64));
long siteTotal = rootNodes.Sum(n => n.TotalSizeBytes); long siteTotal = 0, versionTotal = 0, fileTotal = 0;
long versionTotal = rootNodes.Sum(n => n.VersionSizeBytes); foreach (var n in nodes)
long fileTotal = rootNodes.Sum(n => n.TotalFileCount); {
if (n.IndentLevel != 0) continue;
rootNodes.Add(n);
siteTotal += n.TotalSizeBytes;
versionTotal += n.VersionSizeBytes;
fileTotal += n.TotalFileCount;
}
sb.AppendLine("<div class=\"stats\">"); sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>"); sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>");
@@ -180,15 +213,7 @@ public class StorageHtmlExportService
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount); var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
sb.AppendLine("<div class=\"chart-section\">"); 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} {T["report.text.files_unit"]}, {FormatSize(totalSize)})</h2>");
=======
<<<<<<< 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
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578", var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" }; "#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
@@ -220,6 +245,7 @@ public class StorageHtmlExportService
<thead> <thead>
<tr> <tr>
<th>{T["report.col.library_folder"]}</th> <th>{T["report.col.library_folder"]}</th>
<th>{T["stor.col.kind"]}</th>
<th>{T["report.col.site"]}</th> <th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th> <th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th> <th class="num">{T["report.stat.total_size"]}</th>
@@ -230,7 +256,10 @@ public class StorageHtmlExportService
<tbody> <tbody>
"""); """);
foreach (var node in nodes) // Render only the pre-materialized root list — recursing into
// Children handles descendants. Iterating the flat list would render
// every descendant a second time as a top-level row.
foreach (var node in rootNodes)
{ {
RenderNode(sb, node); RenderNode(sb, node);
} }
@@ -242,22 +271,24 @@ public class StorageHtmlExportService
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>"); sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>"); sb.AppendLine("</body></html>");
return sb.ToString();
} }
/// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary> /// <summary>Writes the library-only HTML report to <paramref name="filePath"/>.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null) public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct, ReportBranding? branding = null)
{ {
var html = BuildHtml(nodes, branding); // Build into StringBuilder, stream chunks straight to disk —
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct); // skips a full-document char-array copy from sb.ToString().
var sb = new StringBuilder(3072 + nodes.Count * 340);
BuildHtmlCore(sb, nodes, branding);
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
} }
/// <summary>Writes the HTML report including the file-type breakdown chart.</summary> /// <summary>Writes the HTML report including the file-type breakdown chart.</summary>
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null) public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct, ReportBranding? branding = null)
{ {
var html = BuildHtml(nodes, fileTypeMetrics, branding); var sb = new StringBuilder(4096 + nodes.Count * 340 + fileTypeMetrics.Count * 220);
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct); BuildHtmlCore(sb, nodes, fileTypeMetrics, branding);
await ExportFileWriter.WriteHtmlChunksAsync(filePath, sb, ct);
} }
/// <summary> /// <summary>
@@ -313,24 +344,11 @@ public class StorageHtmlExportService
? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}" ? $"<button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}"
: $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>"; : $"<span style=\"margin-left:{node.IndentLevel * 16}px\">{HtmlEncode(node.Name)}</span>";
string lastMod = node.LastModified.HasValue AppendRow(sb, node, nameCell);
? node.LastModified.Value.ToString("yyyy-MM-dd")
: string.Empty;
sb.AppendLine($"""
<tr>
<td>{nameCell}</td>
<td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
<td>{lastMod}</td>
</tr>
""");
if (hasChildren) if (hasChildren)
{ {
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">"); sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>"); sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children) foreach (var child in node.Children)
{ {
@@ -351,24 +369,11 @@ public class StorageHtmlExportService
? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}</span>" ? $"<span style=\"{indent}\"><button class=\"toggle-btn\" onclick=\"toggle({myIdx})\">&#9654;</button>{HtmlEncode(node.Name)}</span>"
: $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>"; : $"<span style=\"{indent}\">{HtmlEncode(node.Name)}</span>";
string lastMod = node.LastModified.HasValue AppendRow(sb, node, nameCell);
? node.LastModified.Value.ToString("yyyy-MM-dd")
: string.Empty;
sb.AppendLine($"""
<tr>
<td>{nameCell}</td>
<td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
<td>{lastMod}</td>
</tr>
""");
if (hasChildren) if (hasChildren)
{ {
sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"6\" style=\"padding:0\">"); sb.AppendLine($"<tr id=\"sf-{myIdx}\" style=\"display:none\"><td colspan=\"7\" style=\"padding:0\">");
sb.AppendLine("<table class=\"sf-tbl\"><tbody>"); sb.AppendLine("<table class=\"sf-tbl\"><tbody>");
foreach (var child in node.Children) foreach (var child in node.Children)
{ {
@@ -379,6 +384,35 @@ public class StorageHtmlExportService
} }
} }
/// <summary>
/// Appends one data row given the pre-rendered name cell. Hot path:
/// pulls localized kind labels from <see cref="_kindLabelsHtml"/> instead
/// of going through <c>ResourceManager.GetString</c> + <c>HtmlEncode</c>
/// per row.
/// </summary>
private void AppendRow(StringBuilder sb, StorageNode node, string nameCell)
{
int kindIdx = (int)node.Kind;
string kindLabel = (uint)kindIdx < (uint)_kindLabelsHtml.Length
? _kindLabelsHtml[kindIdx]
: HtmlEncode(node.Kind.ToString());
string lastMod = node.LastModified.HasValue
? node.LastModified.Value.ToString("yyyy-MM-dd")
: string.Empty;
sb.AppendLine($"""
<tr>
<td>{nameCell}</td>
<td>{kindLabel}</td>
<td>{HtmlEncode(node.SiteTitle)}</td>
<td class="num">{node.TotalFileCount:N0}</td>
<td class="num">{FormatSize(node.TotalSizeBytes)}</td>
<td class="num">{FormatSize(node.VersionSizeBytes)}</td>
<td>{lastMod}</td>
</tr>
""");
}
private static string FormatSize(long bytes) private static string FormatSize(long bytes)
{ {
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB"; if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
@@ -389,4 +423,43 @@ public class StorageHtmlExportService
private static string HtmlEncode(string value) private static string HtmlEncode(string value)
=> System.Net.WebUtility.HtmlEncode(value ?? string.Empty); => System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
private static string KindLabel(StorageNodeKind kind)
{
var T = TranslationSource.Instance;
return kind switch
{
StorageNodeKind.Library => T["stor.kind.library"],
StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
/// <summary>
/// Pre-resolves localized labels for every <see cref="StorageNodeKind"/>
/// once per export. Cached array index lookup avoids
/// <c>ResourceManager.GetString</c> per row in hot rendering loops.
/// </summary>
private static string[] BuildKindLabelCache()
{
var values = (StorageNodeKind[])Enum.GetValues(typeof(StorageNodeKind));
int max = 0;
foreach (var v in values) { int i = (int)v; if (i > max) max = i; }
var cache = new string[max + 1];
for (int i = 0; i < cache.Length; i++) cache[i] = ((StorageNodeKind)i).ToString();
foreach (var v in values) cache[(int)v] = KindLabel(v);
return cache;
}
/// <summary>HTML-encodes each entry of <paramref name="raw"/> once.</summary>
private static string[] BuildHtmlEncodedCache(string[] raw)
{
var encoded = new string[raw.Length];
for (int i = 0; i < raw.Length; i++) encoded[i] = HtmlEncode(raw[i]);
return encoded;
}
} }
@@ -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";
}
}
+19 -8
View File
@@ -6,8 +6,11 @@ namespace SharepointToolbox.Services;
public interface IStorageService public interface IStorageService
{ {
/// <summary> /// <summary>
/// Collects storage metrics per library/folder using SharePoint StorageMetrics API. /// Collects storage metrics for a site, capturing every storage source
/// Returns a tree of StorageNode objects with aggregate size data. /// SharePoint reports (visible + hidden libraries, Preservation Hold,
/// list attachments, recycle bin, and optionally subsites). Each
/// <see cref="StorageNode"/> carries a <see cref="StorageNodeKind"/> so
/// callers can filter what appears in the report.
/// </summary> /// </summary>
Task<IReadOnlyList<StorageNode>> CollectStorageAsync( Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx, ClientContext ctx,
@@ -18,9 +21,6 @@ public interface IStorageService
/// <summary> /// <summary>
/// Enumerates files across all non-hidden document libraries in the site /// Enumerates files across all non-hidden document libraries in the site
/// and aggregates storage consumption grouped by file extension. /// and aggregates storage consumption grouped by file extension.
/// Uses CSOM CamlQuery to retrieve FileLeafRef and File_x0020_Size per file.
/// This is a separate operation from CollectStorageAsync -- it provides
/// file-type breakdown data for chart visualization.
/// </summary> /// </summary>
Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync( Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx, ClientContext ctx,
@@ -29,13 +29,24 @@ public interface IStorageService
/// <summary> /// <summary>
/// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes /// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes
/// by enumerating files per library via CamlQuery. /// by enumerating files per library via CamlQuery. Only re-runs against
/// This works around the StorageMetrics API returning zeros when the /// document-library kinds (Library, HiddenLibrary, PreservationHold).
/// caller lacks sufficient permissions or metrics haven't been calculated.
/// </summary> /// </summary>
Task BackfillZeroNodesAsync( Task BackfillZeroNodesAsync(
ClientContext ctx, ClientContext ctx,
IReadOnlyList<StorageNode> nodes, IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct); CancellationToken ct);
/// <summary>
/// Returns the SharePoint-reported total storage usage for the site
/// (Site.Usage.Storage). This includes everything that counts toward
/// the site quota — recycle bin, version history, hidden libraries,
/// list attachments — and serves as the ground-truth reference total.
/// Returns 0 if the call is denied or the property is unavailable.
/// </summary>
Task<long> GetSiteUsageStorageBytesAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct);
} }
@@ -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);
}
+502 -198
View File
@@ -7,27 +7,39 @@ namespace SharepointToolbox.Services;
/// <summary> /// <summary>
/// CSOM-based storage metrics scanner. /// CSOM-based storage metrics scanner.
/// Port of PowerShell Collect-FolderStorage / Get-PnPFolderStorageMetric pattern. /// Captures every storage source SharePoint reports for a site:
/// document libraries (visible + hidden), the Preservation Hold Library,
/// list attachments, the recycle bin (1st + 2nd stage), and optionally
/// subsites. Each <see cref="StorageNode"/> carries a <see cref="StorageNodeKind"/>
/// so the caller can filter what appears in the report.
/// </summary> /// </summary>
public class StorageService : IStorageService public class StorageService : IStorageService
{ {
/// <summary> // PreservationHoldLibrary base template id.
/// Collects per-library and per-folder storage metrics for a single private const int PreservationHoldTemplate = 851;
/// SharePoint site. Depth and indentation are controlled via
/// <paramref name="options"/>; libraries flagged <c>Hidden</c> are skipped.
/// Traversal is breadth-first and leans on <see cref="SharePointPaginationHelper"/>
/// so libraries above the 5,000-item threshold remain scannable.
/// </summary>
public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync( public async Task<IReadOnlyList<StorageNode>> CollectStorageAsync(
ClientContext ctx, ClientContext ctx,
StorageScanOptions options, StorageScanOptions options,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{
var result = new List<StorageNode>();
await CollectForWebAsync(ctx, ctx.Web, options, result, progress, ct);
return result;
}
private async Task CollectForWebAsync(
ClientContext ctx,
Web web,
StorageScanOptions options,
List<StorageNode> result,
IProgress<OperationProgress> progress,
CancellationToken ct)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Load web-level metadata in one round-trip ctx.Load(web,
ctx.Load(ctx.Web,
w => w.Title, w => w.Title,
w => w.Url, w => w.Url,
w => w.ServerRelativeUrl, w => w.ServerRelativeUrl,
@@ -35,48 +47,326 @@ public class StorageService : IStorageService
l => l.Title, l => l.Title,
l => l.Hidden, l => l.Hidden,
l => l.BaseType, l => l.BaseType,
l => l.BaseTemplate,
l => l.ItemCount,
l => l.RootFolder.ServerRelativeUrl)); l => l.RootFolder.ServerRelativeUrl));
if (options.IncludeSubsites)
ctx.Load(web.Webs, ws => ws.Include(w => w.ServerRelativeUrl, w => w.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
string webSrl = ctx.Web.ServerRelativeUrl.TrimEnd('/'); string siteTitle = web.Title;
string siteTitle = ctx.Web.Title; var lists = web.Lists.ToList();
var result = new List<StorageNode>();
var libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
// ── Document libraries (incl. hidden + Preservation Hold) ───────────
// Track each library's RootFolder server-relative URL so bin items can
// be attributed back to their source library (matches storman.aspx,
// which folds bin contents into the owning library's Total Size).
var docLibs = lists.Where(l => l.BaseType == BaseType.DocumentLibrary).ToList();
var libsByRoot = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
int idx = 0; int idx = 0;
foreach (var lib in libs) foreach (var lib in docLibs)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
idx++; idx++;
progress.Report(new OperationProgress(idx, libs.Count,
$"Loading storage metrics: {lib.Title} ({idx}/{libs.Count})")); StorageNodeKind kind = ClassifyLibrary(lib);
if (kind == StorageNodeKind.HiddenLibrary && !options.IncludeHiddenLibraries) continue;
if (kind == StorageNodeKind.PreservationHold && !options.IncludePreservationHold) continue;
progress.Report(new OperationProgress(idx, docLibs.Count,
$"Loading storage metrics: {lib.Title} ({idx}/{docLibs.Count})"));
var libNode = await LoadFolderNodeAsync( var libNode = await LoadFolderNodeAsync(
ctx, lib.RootFolder.ServerRelativeUrl, lib.Title, ctx, lib.RootFolder.ServerRelativeUrl, lib.Title,
siteTitle, lib.Title, 0, progress, ct); siteTitle, lib.Title, 0, kind, progress, ct);
if (options.FolderDepth > 0) if (options.FolderDepth > 0)
{ {
await CollectSubfoldersAsync( await CollectSubfoldersAsync(
ctx, lib, lib.RootFolder.ServerRelativeUrl, ctx, lib, lib.RootFolder.ServerRelativeUrl,
libNode, 1, options.FolderDepth, libNode, 1, options.FolderDepth,
siteTitle, lib.Title, progress, ct); siteTitle, lib.Title, kind, progress, ct);
} }
// CSOM Folder.StorageMetrics is unreliable across the board for
// larger libraries — sometimes returns the storman value, sometimes
// returns a fraction of it, sometimes zero. Subfolder StorageMetrics
// are equally inconsistent. The only CSOM path that matches storman
// is per-file File.Length + File.Versions[*].Size enumeration, so
// run it unconditionally, replacing the CSOM totals.
ResetNodeCounts(libNode);
await BackfillLibFromFilesAsync(ctx, lib, libNode, progress, ct);
result.Add(libNode); result.Add(libNode);
libsByRoot[NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl)] = libNode;
} }
return result; // ── List attachments (non-document-library lists) ───────────────────
if (options.IncludeListAttachments)
{
var nonDocLists = lists
.Where(l => l.BaseType != BaseType.DocumentLibrary && !l.Hidden && l.ItemCount > 0)
.ToList();
int aIdx = 0;
foreach (var list in nonDocLists)
{
ct.ThrowIfCancellationRequested();
aIdx++;
progress.Report(new OperationProgress(aIdx, nonDocLists.Count,
$"Scanning list attachments: {list.Title} ({aIdx}/{nonDocLists.Count})"));
var attachNode = await TryLoadAttachmentsNodeAsync(ctx, list, siteTitle, progress, ct);
if (attachNode != null && attachNode.TotalSizeBytes > 0)
result.Add(attachNode);
}
}
// ── Recycle bin (stage 1 + stage 2) ─────────────────────────────────
if (options.IncludeRecycleBin)
{
progress.Report(OperationProgress.Indeterminate(
$"Scanning recycle bin: {siteTitle}..."));
var (rbNodes, perDir) = await LoadRecycleBinNodesAsync(ctx, web, siteTitle, progress, ct);
// Attribute bin items to owning library (longest-prefix match on DirName)
// so library Total Size matches storman.aspx, which counts an item's
// bytes against its source library even after deletion.
if (perDir.Count > 0 && libsByRoot.Count > 0)
{
var libRootsByLength = libsByRoot
.OrderByDescending(kv => kv.Key.Length)
.ToList();
foreach (var kv in perDir)
{
string dirNorm = NormalizeServerRelative(kv.Key);
foreach (var lib in libRootsByLength)
{
if (dirNorm.Equals(lib.Key, StringComparison.OrdinalIgnoreCase) ||
dirNorm.StartsWith(lib.Key + "/", StringComparison.OrdinalIgnoreCase))
{
lib.Value.TotalSizeBytes += kv.Value.Size;
lib.Value.TotalFileCount += kv.Value.Count;
break;
}
}
}
}
result.AddRange(rbNodes);
}
// ── Subsites (recursive) ────────────────────────────────────────────
if (options.IncludeSubsites)
{
var subwebs = web.Webs.ToList();
foreach (var sub in subwebs)
{
ct.ThrowIfCancellationRequested();
// Build a node header so subsite results are visually grouped.
var subResult = new List<StorageNode>();
await CollectForWebAsync(ctx, sub, options, subResult, progress, ct);
if (subResult.Count == 0) continue;
// Bin contents already rolled up into each library's TotalSizeBytes
// (storman behavior); summing root RecycleBin children too would
// double-count. Filter them out here.
var subRoot = new StorageNode
{
Name = sub.Title,
Url = ctx.Url.TrimEnd('/') + sub.ServerRelativeUrl,
SiteTitle = sub.Title,
Library = string.Empty,
Kind = StorageNodeKind.Subsite,
IndentLevel = 0,
Children = subResult,
TotalSizeBytes = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.TotalSizeBytes),
FileStreamSizeBytes = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.FileStreamSizeBytes),
TotalFileCount = subResult.Where(n => n.Kind != StorageNodeKind.RecycleBin).Sum(n => n.TotalFileCount)
};
result.Add(subRoot);
}
}
}
private static StorageNodeKind ClassifyLibrary(List lib)
{
if (lib.BaseTemplate == PreservationHoldTemplate ||
string.Equals(lib.Title, "Preservation Hold Library", StringComparison.OrdinalIgnoreCase))
return StorageNodeKind.PreservationHold;
return lib.Hidden ? StorageNodeKind.HiddenLibrary : StorageNodeKind.Library;
}
private static async Task<StorageNode?> TryLoadAttachmentsNodeAsync(
ClientContext ctx,
List list,
string siteTitle,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// Per-list attachments live in <listRootFolder>/Attachments/<itemId>/<file>.
// The Attachments folder may or may not exist depending on whether any
// item ever had an attachment — guard with try/catch.
string attachmentsUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/') + "/Attachments";
try
{
var folder = ctx.Web.GetFolderByServerRelativeUrl(attachmentsUrl);
ctx.Load(folder,
f => f.Exists,
f => f.StorageMetrics,
f => f.TimeLastModified,
f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
if (!folder.Exists || folder.StorageMetrics.TotalFileCount == 0)
return null;
DateTime? lastMod = folder.StorageMetrics.LastModified > DateTime.MinValue
? folder.StorageMetrics.LastModified
: folder.TimeLastModified > DateTime.MinValue
? folder.TimeLastModified
: (DateTime?)null;
return new StorageNode
{
Name = $"[Attachments] {list.Title}",
Url = ctx.Url.TrimEnd('/') + attachmentsUrl,
SiteTitle = siteTitle,
Library = list.Title,
Kind = StorageNodeKind.ListAttachments,
TotalSizeBytes = folder.StorageMetrics.TotalSize,
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = folder.StorageMetrics.TotalFileCount,
LastModified = lastMod,
IndentLevel = 0,
Children = new List<StorageNode>()
};
}
catch
{
// Attachments folder absent for this list — not an error.
return null;
}
}
private static async Task<(List<StorageNode> Nodes, Dictionary<string, (long Size, int Count)> PerDir)> LoadRecycleBinNodesAsync(
ClientContext ctx,
Web web,
string siteTitle,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var nodes = new List<StorageNode>();
var perDir = new Dictionary<string, (long Size, int Count)>(StringComparer.OrdinalIgnoreCase);
try
{
// Web-scoped: ctx.Site.RecycleBin would return the entire site-collection
// bin and inflate totals by (1 + N_subsites) when IncludeSubsites is on.
var bin = web.RecycleBin;
ctx.Load(bin, b => b.Include(
i => i.Size,
i => i.ItemState,
i => i.DeletedDate,
i => i.DirName));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
// RecycleBinItem.DirName is web-relative on SharePoint Online
// (e.g. "Documents/SubFolder" without leading slash or web URL).
// Prepend the web's ServerRelativeUrl so the result matches
// List.RootFolder.ServerRelativeUrl form used by libsByRoot.
string webSrl = NormalizeServerRelative(web.ServerRelativeUrl);
long stage1Size = 0, stage2Size = 0;
int stage1Count = 0, stage2Count = 0;
DateTime? stage1Last = null, stage2Last = null;
foreach (var item in bin)
{
if (item.ItemState == RecycleBinItemState.SecondStageRecycleBin)
{
stage2Size += item.Size;
stage2Count++;
if (stage2Last is null || item.DeletedDate > stage2Last) stage2Last = item.DeletedDate;
}
else
{
stage1Size += item.Size;
stage1Count++;
if (stage1Last is null || item.DeletedDate > stage1Last) stage1Last = item.DeletedDate;
}
string raw = item.DirName ?? string.Empty;
string dirSrl;
if (raw.StartsWith('/'))
dirSrl = NormalizeServerRelative(raw);
else if (string.IsNullOrEmpty(raw))
dirSrl = webSrl;
else
dirSrl = NormalizeServerRelative(webSrl + "/" + raw);
if (perDir.TryGetValue(dirSrl, out var tally))
perDir[dirSrl] = (tally.Size + item.Size, tally.Count + 1);
else
perDir[dirSrl] = (item.Size, 1);
}
if (stage1Count > 0)
nodes.Add(new StorageNode
{
Name = "[Recycle Bin] First-stage",
SiteTitle = siteTitle,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
TotalSizeBytes = stage1Size,
FileStreamSizeBytes = stage1Size,
TotalFileCount = stage1Count,
LastModified = stage1Last,
IndentLevel = 0,
Children = new List<StorageNode>()
});
if (stage2Count > 0)
nodes.Add(new StorageNode
{
Name = "[Recycle Bin] Second-stage",
SiteTitle = siteTitle,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
TotalSizeBytes = stage2Size,
FileStreamSizeBytes = stage2Size,
TotalFileCount = stage2Count,
LastModified = stage2Last,
IndentLevel = 0,
Children = new List<StorageNode>()
});
}
catch
{
// Insufficient permission to read recycle bin or feature unavailable.
}
return (nodes, perDir);
} }
/// <summary> /// <summary>
/// Aggregates file counts and total sizes by extension across every /// Normalizes a server-relative path for consistent prefix matching:
/// non-hidden document library on the site. Extensions are normalised to /// trims trailing slash, ensures single leading slash. SharePoint
/// lowercase; files without an extension roll up into a single bucket. /// inconsistently returns DirName with or without leading slash across
/// API surfaces, so the caller cannot rely on a canonical form.
/// </summary> /// </summary>
private static string NormalizeServerRelative(string? path)
{
if (string.IsNullOrEmpty(path)) return string.Empty;
string trimmed = path.Trim().TrimEnd('/');
if (trimmed.Length == 0) return string.Empty;
return trimmed.StartsWith('/') ? trimmed : "/" + trimmed;
}
public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync( public async Task<IReadOnlyList<FileTypeMetric>> CollectFileTypeMetricsAsync(
ClientContext ctx, ClientContext ctx,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
@@ -84,7 +374,6 @@ public class StorageService : IStorageService
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Load all non-hidden document libraries
ctx.Load(ctx.Web, ctx.Load(ctx.Web,
w => w.Lists.Include( w => w.Lists.Include(
l => l.Title, l => l.Title,
@@ -97,7 +386,6 @@ public class StorageService : IStorageService
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary) .Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList(); .ToList();
// Accumulate file sizes by extension across all libraries
var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase); var extensionMap = new Dictionary<string, (long totalSize, int count)>(StringComparer.OrdinalIgnoreCase);
int libIdx = 0; int libIdx = 0;
@@ -108,9 +396,10 @@ public class StorageService : IStorageService
progress.Report(new OperationProgress(libIdx, libs.Count, progress.Report(new OperationProgress(libIdx, libs.Count,
$"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})")); $"Scanning files by type: {lib.Title} ({libIdx}/{libs.Count})"));
// Paginated CAML without a WHERE clause — WHERE on non-indexed fields // No <Where> clause: filtering on FSObjType (non-indexed) on a list
// (FSObjType) throws list-view threshold on libraries > 5,000 items. // beyond 5000 items breaches the list view threshold. Page lightly,
// Filter files client-side via FSObjType. // then second-pass load File.Length + Versions[*].Size so per-type
// totals include version bytes (matches per-library totals).
var query = new CamlQuery var query = new CamlQuery
{ {
ViewXml = @"<View Scope='RecursiveAll'> ViewXml = @"<View Scope='RecursiveAll'>
@@ -118,9 +407,8 @@ public class StorageService : IStorageService
<ViewFields> <ViewFields>
<FieldRef Name='FSObjType' /> <FieldRef Name='FSObjType' />
<FieldRef Name='FileLeafRef' /> <FieldRef Name='FileLeafRef' />
<FieldRef Name='File_x0020_Size' />
</ViewFields> </ViewFields>
<RowLimit Paged='TRUE'>5000</RowLimit> <RowLimit Paged='TRUE'>500</RowLimit>
</View>" </View>"
}; };
@@ -132,21 +420,40 @@ public class StorageService : IStorageService
ctx.Load(items, ic => ic.ListItemCollectionPosition, ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include( ic => ic.Include(
i => i["FSObjType"], i => i["FSObjType"],
i => i["FileLeafRef"], i => i["FileLeafRef"]));
i => i["File_x0020_Size"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var fileRows = new List<(ListItem Item, string Name)>();
foreach (var item in items) foreach (var item in items)
{ {
if (item["FSObjType"]?.ToString() != "0") continue; // skip folders if (item["FSObjType"]?.ToString() != "0") continue;
string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty; string fileName = item["FileLeafRef"]?.ToString() ?? string.Empty;
string sizeStr = item["File_x0020_Size"]?.ToString() ?? "0"; fileRows.Add((item, fileName));
ctx.Load(item.File, f => f.Length);
ctx.Load(item.File.Versions, vc => vc.Include(v => v.Size));
}
if (!long.TryParse(sizeStr, out long fileSize)) if (fileRows.Count > 0)
fileSize = 0; {
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
string ext = Path.GetExtension(fileName).ToLowerInvariant(); foreach (var row in fileRows)
{
long current;
try { current = row.Item.File.Length; }
catch { continue; }
long versions = 0;
try
{
foreach (var v in row.Item.File.Versions)
versions += v.Size;
}
catch { /* no version history */ }
long fileSize = current + versions;
string ext = Path.GetExtension(row.Name).ToLowerInvariant();
if (extensionMap.TryGetValue(ext, out var existing)) if (extensionMap.TryGetValue(ext, out var existing))
extensionMap[ext] = (existing.totalSize + fileSize, existing.count + 1); extensionMap[ext] = (existing.totalSize + fileSize, existing.count + 1);
@@ -159,173 +466,173 @@ public class StorageService : IStorageService
while (items.ListItemCollectionPosition != null); while (items.ListItemCollectionPosition != null);
} }
// Convert to FileTypeMetric list, sorted by size descending
return extensionMap return extensionMap
.Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count)) .Select(kvp => new FileTypeMetric(kvp.Key, kvp.Value.totalSize, kvp.Value.count))
.OrderByDescending(m => m.TotalSizeBytes) .OrderByDescending(m => m.TotalSizeBytes)
.ToList(); .ToList();
} }
public async Task BackfillZeroNodesAsync( /// <summary>
/// Per-library backfill executed inline by CollectForWebAsync when CSOM's
/// Folder.StorageMetrics returns zero counts. Enumerates every file via
/// CamlQuery and explicitly loads File.Length + File.Versions.Size so
/// version bytes are summed accurately — matches what storman.aspx reports.
/// </summary>
private static async Task BackfillLibFromFilesAsync(
ClientContext ctx,
List lib,
StorageNode libNode,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
progress.Report(OperationProgress.Indeterminate(
$"Counting files: {libNode.Name}..."));
string libRootSrl = NormalizeServerRelative(lib.RootFolder.ServerRelativeUrl);
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
BuildFolderLookup(libNode, libRootSrl, folderLookup);
// No <Where> clause: filtering on FSObjType (non-indexed) on a list
// beyond the 5000-item view threshold throws "The attempted operation
// is prohibited because it exceeds the list view threshold". Paged
// retrieval without Where is unaffected by the threshold; we filter
// out folders client-side and skip File.Length access for them.
// Smaller page size because each row carries the full Versions collection.
var query = new CamlQuery
{
ViewXml = @"<View Scope='RecursiveAll'>
<Query></Query>
<ViewFields>
<FieldRef Name='FSObjType' />
<FieldRef Name='FileDirRef' />
</ViewFields>
<RowLimit Paged='TRUE'>500</RowLimit>
</View>"
};
ListItemCollection items;
do
{
ct.ThrowIfCancellationRequested();
items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include(
i => i["FSObjType"],
i => i["FileDirRef"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
// Second pass: queue File.Length + File.Versions[*].Size only for
// file rows. Including these in the page 1 query throws a
// ServerObjectNullReferenceException on folder rows (item.File is
// null for folders). Filtering FSObjType client-side here keeps
// per-page round-trips at two regardless of file count.
var fileRows = new List<(ListItem Item, string DirRef)>();
foreach (var item in items)
{
if (item["FSObjType"]?.ToString() != "0") continue;
var dirRef = item["FileDirRef"]?.ToString() ?? string.Empty;
fileRows.Add((item, dirRef));
ctx.Load(item.File, f => f.Length);
ctx.Load(item.File.Versions, vc => vc.Include(v => v.Size));
}
if (fileRows.Count > 0)
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
foreach (var row in fileRows)
{
long current;
try { current = row.Item.File.Length; }
catch { continue; }
long versions = 0;
try
{
foreach (var v in row.Item.File.Versions)
versions += v.Size;
}
catch
{
// Versioning disabled / no version history — leave at 0.
}
long totalSize = current + versions;
// Attribute each file to its deepest matching folder only.
// Parent rollup happens once after all pages are processed,
// adding direct + descendants — matches storman's per-folder
// total. Fall back to libNode for files at lib root or in
// folders excluded from the tree (Forms, _-prefixed system
// folders, depth-limited subfolders).
var target = FindDeepestFolder(row.DirRef, folderLookup) ?? libNode;
target.TotalSizeBytes += totalSize;
target.FileStreamSizeBytes += current;
target.TotalFileCount++;
}
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (items.ListItemCollectionPosition != null);
// Post-pass rollup: each folder's totals become own-direct + sum of
// descendants. libNode ends up as total of every file in the tree.
RollupFolderTotals(libNode);
}
/// <summary>
/// Recursively rolls up direct-file totals into ancestor folders so each
/// node's reported size includes everything beneath it. Pre-condition: each
/// node holds only its directly-attributed files (no descendant amounts).
/// </summary>
private static void RollupFolderTotals(StorageNode node)
{
foreach (var child in node.Children)
{
RollupFolderTotals(child);
node.TotalSizeBytes += child.TotalSizeBytes;
node.FileStreamSizeBytes += child.FileStreamSizeBytes;
node.TotalFileCount += child.TotalFileCount;
}
}
/// <summary>
/// No-op retained for interface compatibility. Backfill now runs inline
/// inside <see cref="CollectStorageAsync"/> via BackfillLibFromFilesAsync,
/// which has access to the CSOM library reference and runs before bin
/// distribution so the count==0 trigger is not polluted by bin items.
/// </summary>
public Task BackfillZeroNodesAsync(
ClientContext ctx, ClientContext ctx,
IReadOnlyList<StorageNode> nodes, IReadOnlyList<StorageNode> nodes,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
=> Task.CompletedTask;
public async Task<long> GetSiteUsageStorageBytesAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct)
{ {
// Find root-level library nodes that have any zero-valued nodes in their tree try
var libNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
var needsBackfill = libNodes.Where(lib =>
lib.TotalFileCount == 0 || HasZeroChild(lib)).ToList();
if (needsBackfill.Count == 0) return;
// Load libraries to get RootFolder.ServerRelativeUrl for path matching
ctx.Load(ctx.Web, 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 libs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToDictionary(l => l.Title, StringComparer.OrdinalIgnoreCase);
int idx = 0;
foreach (var libNode in needsBackfill)
{ {
ct.ThrowIfCancellationRequested(); ctx.Load(ctx.Site, s => s.Usage);
idx++; await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
return ctx.Site.Usage.Storage;
if (!libs.TryGetValue(libNode.Name, out var lib)) continue;
progress.Report(new OperationProgress(idx, needsBackfill.Count,
$"Counting files: {libNode.Name} ({idx}/{needsBackfill.Count})"));
string libRootSrl = lib.RootFolder.ServerRelativeUrl.TrimEnd('/');
// Build a lookup of all folder nodes in this library's tree (by server-relative path)
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
BuildFolderLookup(libNode, libRootSrl, folderLookup);
// Capture original TotalSizeBytes before reset — StorageMetrics.TotalSize
// includes version overhead, which cannot be rederived from a file scan
// (File_x0020_Size is the current stream size only).
var originalTotals = new Dictionary<StorageNode, long>();
CaptureTotals(libNode, originalTotals);
// Reset all nodes in this tree to zero before accumulating
ResetNodeCounts(libNode);
// Paginated CAML without WHERE (filter folders client-side via FSObjType).
// SMTotalSize = per-file total including all versions (version-aware).
// SMTotalFileStreamSize = current stream only. File_x0020_Size is a fallback
// when SMTotalSize is unavailable (older tenants / custom fields stripped).
var query = new CamlQuery
{
ViewXml = @"<View Scope='RecursiveAll'>
<Query></Query>
<ViewFields>
<FieldRef Name='FSObjType' />
<FieldRef Name='FileDirRef' />
<FieldRef Name='File_x0020_Size' />
<FieldRef Name='SMTotalSize' />
<FieldRef Name='SMTotalFileStreamSize' />
</ViewFields>
<RowLimit Paged='TRUE'>5000</RowLimit>
</View>"
};
ListItemCollection items;
do
{
ct.ThrowIfCancellationRequested();
items = lib.GetItems(query);
ctx.Load(items, ic => ic.ListItemCollectionPosition,
ic => ic.Include(
i => i["FSObjType"],
i => i["FileDirRef"],
i => i["File_x0020_Size"],
i => i["SMTotalSize"],
i => i["SMTotalFileStreamSize"]));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var item in items)
{
if (item["FSObjType"]?.ToString() != "0") continue; // skip folders
long streamSize = ParseLong(item["File_x0020_Size"]);
long smStream = ParseLong(SafeGet(item, "SMTotalFileStreamSize"));
long smTotal = ParseLong(SafeGet(item, "SMTotalSize"));
// Prefer SM fields when present; fall back to File_x0020_Size otherwise.
if (smStream > 0) streamSize = smStream;
long totalSize = smTotal > 0 ? smTotal : streamSize;
string fileDirRef = item["FileDirRef"]?.ToString() ?? "";
// Always count toward the library root
libNode.TotalSizeBytes += totalSize;
libNode.FileStreamSizeBytes += streamSize;
libNode.TotalFileCount++;
// Also count toward the most specific matching subfolder
var matchedFolder = FindDeepestFolder(fileDirRef, folderLookup);
if (matchedFolder != null && matchedFolder != libNode)
{
matchedFolder.TotalSizeBytes += totalSize;
matchedFolder.FileStreamSizeBytes += streamSize;
matchedFolder.TotalFileCount++;
}
}
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (items.ListItemCollectionPosition != null);
// Restore original TotalSizeBytes where it exceeded the recomputed value.
// Preserves StorageMetrics.TotalSize for nodes whose original metrics were
// valid but SMTotalSize was missing on individual files.
foreach (var kv in originalTotals)
{
if (kv.Value > kv.Key.TotalSizeBytes)
kv.Key.TotalSizeBytes = kv.Value;
}
} }
} catch
private static long ParseLong(object? value)
{
if (value == null) return 0;
return long.TryParse(value.ToString(), out long n) ? n : 0;
}
private static object? SafeGet(ListItem item, string fieldName)
{
try { return item[fieldName]; }
catch { return null; }
}
private static void CaptureTotals(StorageNode node, Dictionary<StorageNode, long> map)
{
map[node] = node.TotalSizeBytes;
foreach (var child in node.Children)
CaptureTotals(child, map);
}
private static bool HasZeroChild(StorageNode node)
{
foreach (var child in node.Children)
{ {
if (child.TotalFileCount == 0) return true; return 0L;
if (HasZeroChild(child)) return true;
} }
return false;
} }
private static void ResetNodeCounts(StorageNode node) private static void ResetNodeCounts(StorageNode node)
{ {
node.TotalSizeBytes = 0; node.TotalSizeBytes = 0;
node.FileStreamSizeBytes = 0; node.FileStreamSizeBytes = 0;
node.TotalFileCount = 0; node.TotalFileCount = 0;
foreach (var child in node.Children) foreach (var child in node.Children)
ResetNodeCounts(child); ResetNodeCounts(child);
} }
@@ -345,8 +652,6 @@ public class StorageService : IStorageService
private static StorageNode? FindDeepestFolder(string fileDirRef, private static StorageNode? FindDeepestFolder(string fileDirRef,
Dictionary<string, StorageNode> lookup) Dictionary<string, StorageNode> lookup)
{ {
// fileDirRef is the server-relative folder path, e.g. "/sites/hr/Shared Documents/Reports"
// Try exact match, then walk up until we find a match
string path = fileDirRef.TrimEnd('/'); string path = fileDirRef.TrimEnd('/');
while (!string.IsNullOrEmpty(path)) while (!string.IsNullOrEmpty(path))
{ {
@@ -359,7 +664,7 @@ public class StorageService : IStorageService
return null; return null;
} }
// -- Private helpers ----------------------------------------------------- // ── Library/folder loading helpers ──────────────────────────────────────
private static async Task<StorageNode> LoadFolderNodeAsync( private static async Task<StorageNode> LoadFolderNodeAsync(
ClientContext ctx, ClientContext ctx,
@@ -368,6 +673,7 @@ public class StorageService : IStorageService
string siteTitle, string siteTitle,
string library, string library,
int indentLevel, int indentLevel,
StorageNodeKind kind,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
@@ -393,6 +699,7 @@ public class StorageService : IStorageService
Url = ctx.Url.TrimEnd('/') + serverRelativeUrl, Url = ctx.Url.TrimEnd('/') + serverRelativeUrl,
SiteTitle = siteTitle, SiteTitle = siteTitle,
Library = library, Library = library,
Kind = kind,
TotalSizeBytes = folder.StorageMetrics.TotalSize, TotalSizeBytes = folder.StorageMetrics.TotalSize,
FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize, FileStreamSizeBytes = folder.StorageMetrics.TotalFileStreamSize,
TotalFileCount = folder.StorageMetrics.TotalFileCount, TotalFileCount = folder.StorageMetrics.TotalFileCount,
@@ -411,15 +718,13 @@ public class StorageService : IStorageService
int maxDepth, int maxDepth,
string siteTitle, string siteTitle,
string library, string library,
StorageNodeKind kind,
IProgress<OperationProgress> progress, IProgress<OperationProgress> progress,
CancellationToken ct) CancellationToken ct)
{ {
if (currentDepth > maxDepth) return; if (currentDepth > maxDepth) return;
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
// Enumerate direct child folders via paginated CAML scoped to the parent.
// Folder.Folders lazy loading hits the list-view threshold on libraries
// > 5,000 items; a paged CAML query with no WHERE bypasses it.
var subfolders = new List<(string Name, string ServerRelativeUrl)>(); var subfolders = new List<(string Name, string ServerRelativeUrl)>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync( await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
@@ -427,13 +732,12 @@ public class StorageService : IStorageService
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef" }, viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef" },
ct: ct)) ct: ct))
{ {
if (item["FSObjType"]?.ToString() != "1") continue; // folders only if (item["FSObjType"]?.ToString() != "1") continue;
string name = item["FileLeafRef"]?.ToString() ?? string.Empty; string name = item["FileLeafRef"]?.ToString() ?? string.Empty;
string url = item["FileRef"]?.ToString() ?? string.Empty; string url = item["FileRef"]?.ToString() ?? string.Empty;
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) continue; if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(url)) continue;
// Skip SharePoint system folders
if (name.Equals("Forms", StringComparison.OrdinalIgnoreCase) || if (name.Equals("Forms", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("_", StringComparison.Ordinal)) name.StartsWith("_", StringComparison.Ordinal))
continue; continue;
@@ -447,14 +751,14 @@ public class StorageService : IStorageService
var childNode = await LoadFolderNodeAsync( var childNode = await LoadFolderNodeAsync(
ctx, sub.ServerRelativeUrl, sub.Name, ctx, sub.ServerRelativeUrl, sub.Name,
siteTitle, library, currentDepth, progress, ct); siteTitle, library, currentDepth, kind, progress, ct);
if (currentDepth < maxDepth) if (currentDepth < maxDepth)
{ {
await CollectSubfoldersAsync( await CollectSubfoldersAsync(
ctx, list, sub.ServerRelativeUrl, childNode, ctx, list, sub.ServerRelativeUrl, childNode,
currentDepth + 1, maxDepth, currentDepth + 1, maxDepth,
siteTitle, library, progress, ct); siteTitle, library, kind, progress, ct);
} }
parentNode.Children.Add(childNode); parentNode.Children.Add(childNode);
@@ -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,
};
}
}
}
@@ -8,6 +8,7 @@
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<PublishTrimmed>false</PublishTrimmed> <PublishTrimmed>false</PublishTrimmed>
<StartupObject>SharepointToolbox.App</StartupObject> <StartupObject>SharepointToolbox.App</StartupObject>
<ApplicationIcon>Resources\SPToolbox-logo.ico</ApplicationIcon>
<!-- LiveCharts2 transitive deps (SkiaSharp.Views.WPF, OpenTK) lack net10.0 targets but work at runtime --> <!-- LiveCharts2 transitive deps (SkiaSharp.Views.WPF, OpenTK) lack net10.0 targets but work at runtime -->
<NoWarn>$(NoWarn);NU1701</NoWarn> <NoWarn>$(NoWarn);NU1701</NoWarn>
</PropertyGroup> </PropertyGroup>
@@ -43,6 +44,8 @@
<EmbeddedResource Include="Resources\bulk_add_members.csv" /> <EmbeddedResource Include="Resources\bulk_add_members.csv" />
<EmbeddedResource Include="Resources\bulk_create_sites.csv" /> <EmbeddedResource Include="Resources\bulk_create_sites.csv" />
<EmbeddedResource Include="Resources\folder_structure.csv" /> <EmbeddedResource Include="Resources\folder_structure.csv" />
<Resource Include="Resources\SPToolbox-logo-ico.png" />
<Resource Include="Resources\SPToolbox-logo.ico" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -50,7 +50,6 @@ public partial class MainWindowViewModel : ObservableRecipient
? string.Format(Localization.TranslationSource.Instance["toolbar.globalSites.count"], GlobalSelectedSites.Count) ? string.Format(Localization.TranslationSource.Instance["toolbar.globalSites.count"], GlobalSelectedSites.Count)
: Localization.TranslationSource.Instance["toolbar.globalSites.none"]; : Localization.TranslationSource.Instance["toolbar.globalSites.none"];
public IAsyncRelayCommand ConnectCommand { get; }
public IAsyncRelayCommand ClearSessionCommand { get; } public IAsyncRelayCommand ClearSessionCommand { get; }
public RelayCommand ManageProfilesCommand { get; } public RelayCommand ManageProfilesCommand { get; }
public RelayCommand OpenGlobalSitePickerCommand { get; } public RelayCommand OpenGlobalSitePickerCommand { get; }
@@ -64,7 +63,6 @@ public partial class MainWindowViewModel : ObservableRecipient
_sessionManager = sessionManager; _sessionManager = sessionManager;
_logger = logger; _logger = logger;
ConnectCommand = new AsyncRelayCommand(ConnectAsync, () => SelectedProfile != null);
ClearSessionCommand = new AsyncRelayCommand(ClearSessionAsync, () => SelectedProfile != null); ClearSessionCommand = new AsyncRelayCommand(ClearSessionAsync, () => SelectedProfile != null);
ManageProfilesCommand = new RelayCommand(OpenProfileManagement); ManageProfilesCommand = new RelayCommand(OpenProfileManagement);
OpenGlobalSitePickerCommand = new RelayCommand(ExecuteOpenGlobalSitePicker, () => SelectedProfile != null); OpenGlobalSitePickerCommand = new RelayCommand(ExecuteOpenGlobalSitePicker, () => SelectedProfile != null);
@@ -96,7 +94,6 @@ public partial class MainWindowViewModel : ObservableRecipient
{ {
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(value)); WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(value));
} }
ConnectCommand.NotifyCanExecuteChanged();
ClearSessionCommand.NotifyCanExecuteChanged(); ClearSessionCommand.NotifyCanExecuteChanged();
// Clear global site selection on tenant switch (sites belong to a tenant) // Clear global site selection on tenant switch (sites belong to a tenant)
GlobalSelectedSites.Clear(); GlobalSelectedSites.Clear();
@@ -121,22 +118,6 @@ public partial class MainWindowViewModel : ObservableRecipient
} }
} }
private async Task ConnectAsync()
{
if (SelectedProfile == null) return;
try
{
ConnectionStatus = "Connecting...";
await _sessionManager.GetOrCreateContextAsync(SelectedProfile, CancellationToken.None);
ConnectionStatus = SelectedProfile.Name;
}
catch (Exception ex)
{
ConnectionStatus = "Connection failed";
_logger.LogError(ex, "Failed to connect to tenant {TenantUrl}.", SelectedProfile.TenantUrl);
}
}
private async Task ClearSessionAsync() private async Task ClearSessionAsync()
{ {
if (SelectedProfile == null) return; if (SelectedProfile == null) return;
@@ -346,9 +346,25 @@ public partial class ProfileManagementViewModel : ObservableObject
private bool CanRemoveApp() private bool CanRemoveApp()
=> SelectedProfile != null && SelectedProfile.AppId != null && !IsRegistering; => SelectedProfile != null && SelectedProfile.AppId != null && !IsRegistering;
/// <summary>
/// Set by the view to display the pre-registration warning. Returns true if the
/// user accepts and registration should proceed.
/// </summary>
public Func<string, bool>? ConfirmRegisterApp { get; set; }
private async Task RegisterAppAsync(CancellationToken ct) private async Task RegisterAppAsync(CancellationToken ct)
{ {
if (SelectedProfile == null) return; if (SelectedProfile == null) return;
// Auth caching reduces this to one prompt in the common case, but a fresh
// tenant or different admin account may still trigger up to two — warn so
// the user knows another window is expected after they sign in.
if (ConfirmRegisterApp != null)
{
var msg = string.Format(TranslationSource.Instance["profile.register.warning"], 2);
if (!ConfirmRegisterApp(msg)) return;
}
IsRegistering = true; IsRegistering = true;
ShowFallbackInstructions = false; ShowFallbackInstructions = false;
RegistrationStatus = TranslationSource.Instance["profile.register.checking"]; RegistrationStatus = TranslationSource.Instance["profile.register.checking"];
@@ -214,15 +214,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return; if (dialog.ShowDialog() != true) return;
try try
{ {
<<<<<<< HEAD
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None); await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None);
=======
<<<<<<< HEAD
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None);
=======
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None);
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true }); Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
} }
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); } catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); }
@@ -41,6 +41,39 @@ public partial class StorageViewModel : FeatureViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _isDonutChart = true; private bool _isDonutChart = true;
// ── Scan-time flags (control what is captured during the CSOM scan) ─────
[ObservableProperty] private bool _scanHiddenLibraries = true;
[ObservableProperty] private bool _scanPreservationHold = true;
[ObservableProperty] private bool _scanListAttachments = true;
[ObservableProperty] private bool _scanRecycleBin = true;
// ── Report filter flags (gate which kinds appear in DataGrid + exports) ─
[ObservableProperty] private bool _showLibraries = true;
[ObservableProperty] private bool _showHiddenLibraries = true;
[ObservableProperty] private bool _showPreservationHold = true;
[ObservableProperty] private bool _showListAttachments = true;
[ObservableProperty] private bool _showRecycleBin = true;
[ObservableProperty] private bool _showSubsites = true;
/// <summary>
/// When true, recycle bin stage 1 + stage 2 collapse into a single
/// "[Recycle Bin] Total" row whose size is the sum of both stages.
/// When false, both stages render as separate rows.
/// </summary>
[ObservableProperty] private bool _combineRecycleBinStages = true;
// SPO-reported site total (Site.Usage.Storage). Independent reference
// value the user can compare against the scanned total.
[ObservableProperty] private long _spoReportedTotalSize;
partial void OnShowLibrariesChanged(bool value) => RebuildFilteredResults();
partial void OnShowHiddenLibrariesChanged(bool value) => RebuildFilteredResults();
partial void OnShowPreservationHoldChanged(bool value) => RebuildFilteredResults();
partial void OnShowListAttachmentsChanged(bool value) => RebuildFilteredResults();
partial void OnShowRecycleBinChanged(bool value) => RebuildFilteredResults();
partial void OnShowSubsitesChanged(bool value) => RebuildFilteredResults();
partial void OnCombineRecycleBinStagesChanged(bool value) => RebuildFilteredResults();
/// <summary>0 = Single file, 1 = Split by site.</summary> /// <summary>0 = Single file, 1 = Split by site.</summary>
[ObservableProperty] private int _splitModeIndex; [ObservableProperty] private int _splitModeIndex;
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary> /// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
@@ -111,6 +144,10 @@ public partial class StorageViewModel : FeatureViewModelBase
} }
} }
// Raw scan output — never filtered. RebuildFilteredResults projects this
// into Results based on the Show* flags.
private List<StorageNode> _allNodes = new();
private ObservableCollection<StorageNode> _results = new(); private ObservableCollection<StorageNode> _results = new();
public ObservableCollection<StorageNode> Results public ObservableCollection<StorageNode> Results
{ {
@@ -126,15 +163,36 @@ public partial class StorageViewModel : FeatureViewModelBase
} }
// ── Summary properties (computed from root-level library nodes) ───────── // ── Summary properties (computed from root-level library nodes) ─────────
//
// Recycle-bin contents are rolled into each library's TotalSizeBytes by the
// StorageService (matches storman.aspx). Including the synthetic root-level
// RecycleBin nodes here would double-count those bytes — filter them out.
// SummaryRecycleBinSize below still reads from _allNodes so the bin metric
// remains visible to the user.
/// <summary>Sum of TotalSizeBytes across root-level library nodes.</summary> /// <summary>Sum of TotalSizeBytes across root-level non-bin nodes.</summary>
public long SummaryTotalSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalSizeBytes); public long SummaryTotalSize => Results
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
.Sum(n => n.TotalSizeBytes);
/// <summary>Sum of VersionSizeBytes across root-level library nodes.</summary> /// <summary>Sum of VersionSizeBytes across root-level non-bin nodes.</summary>
public long SummaryVersionSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.VersionSizeBytes); public long SummaryVersionSize => Results
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
.Sum(n => n.VersionSizeBytes);
/// <summary>Sum of TotalFileCount across root-level library nodes.</summary> /// <summary>Sum of TotalFileCount across root-level non-bin nodes.</summary>
public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount); public long SummaryFileCount => Results
.Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
.Sum(n => n.TotalFileCount);
/// <summary>
/// Aggregate recycle-bin size (stage 1 + stage 2 across all sites). Reads
/// from the raw scan so it stays visible even when the user hides the
/// recycle-bin row in the report filter.
/// </summary>
public long SummaryRecycleBinSize => _allNodes
.Where(n => n.Kind == StorageNodeKind.RecycleBin)
.Sum(n => n.TotalSizeBytes);
public bool HasResults => Results.Count > 0; public bool HasResults => Results.Count > 0;
@@ -142,6 +200,7 @@ public partial class StorageViewModel : FeatureViewModelBase
{ {
OnPropertyChanged(nameof(SummaryTotalSize)); OnPropertyChanged(nameof(SummaryTotalSize));
OnPropertyChanged(nameof(SummaryVersionSize)); OnPropertyChanged(nameof(SummaryVersionSize));
OnPropertyChanged(nameof(SummaryRecycleBinSize));
OnPropertyChanged(nameof(SummaryFileCount)); OnPropertyChanged(nameof(SummaryFileCount));
OnPropertyChanged(nameof(HasResults)); OnPropertyChanged(nameof(HasResults));
} }
@@ -176,14 +235,7 @@ public partial class StorageViewModel : FeatureViewModelBase
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
<<<<<<< HEAD
ApplyChartThemeColors(); ApplyChartThemeColors();
=======
<<<<<<< HEAD
ApplyChartThemeColors();
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
if (_themeManager is not null) if (_themeManager is not null)
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries(); _themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
} }
@@ -227,10 +279,15 @@ public partial class StorageViewModel : FeatureViewModelBase
var options = new StorageScanOptions( var options = new StorageScanOptions(
PerLibrary: PerLibrary, PerLibrary: PerLibrary,
IncludeSubsites: IncludeSubsites, IncludeSubsites: IncludeSubsites,
FolderDepth: FolderDepth); FolderDepth: FolderDepth,
IncludeHiddenLibraries: ScanHiddenLibraries,
IncludePreservationHold: ScanPreservationHold,
IncludeListAttachments: ScanListAttachments,
IncludeRecycleBin: ScanRecycleBin);
var allNodes = new List<StorageNode>(); var allNodes = new List<StorageNode>();
var allTypeMetrics = new List<FileTypeMetric>(); var allTypeMetrics = new List<FileTypeMetric>();
long spoReportedTotal = 0;
var autoOwnership = await IsAutoTakeOwnershipEnabled(); var autoOwnership = await IsAutoTakeOwnershipEnabled();
@@ -280,6 +337,8 @@ public partial class StorageViewModel : FeatureViewModelBase
progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}...")); progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}..."));
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct); var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
allTypeMetrics.AddRange(typeMetrics); allTypeMetrics.AddRange(typeMetrics);
spoReportedTotal += await _storageService.GetSiteUsageStorageBytesAsync(ctx, progress, ct);
} }
// Flatten tree for DataGrid display // Flatten tree for DataGrid display
@@ -298,20 +357,113 @@ public partial class StorageViewModel : FeatureViewModelBase
{ {
await dispatcher.InvokeAsync(() => await dispatcher.InvokeAsync(() =>
{ {
Results = new ObservableCollection<StorageNode>(flat); _allNodes = flat;
SpoReportedTotalSize = spoReportedTotal;
RebuildFilteredResults();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics); FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
}); });
} }
else else
{ {
Results = new ObservableCollection<StorageNode>(flat); _allNodes = flat;
SpoReportedTotalSize = spoReportedTotal;
RebuildFilteredResults();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics); FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
} }
} }
/// <summary>
/// Project <see cref="_allNodes"/> into <see cref="Results"/> using the
/// Show* flags. Nodes whose root ancestor is excluded by the flags are
/// dropped along with their entire subtree, preserving DFS ordering.
/// </summary>
private void RebuildFilteredResults()
{
if (_allNodes.Count == 0)
{
Results = new ObservableCollection<StorageNode>();
return;
}
var filtered = new List<StorageNode>(_allNodes.Count);
bool includeCurrentSubtree = true;
foreach (var node in _allNodes)
{
if (node.IndentLevel == 0)
includeCurrentSubtree = IsKindShown(node.Kind);
if (includeCurrentSubtree)
filtered.Add(node);
}
if (CombineRecycleBinStages)
filtered = CombineRecycleBins(filtered);
Results = new ObservableCollection<StorageNode>(filtered);
}
/// <summary>
/// Replaces all root recycle-bin nodes (stage 1 + stage 2) with a single
/// aggregate row inserted at the position of the first recycle-bin node
/// encountered. Preserves SiteTitle grouping when scans cover multiple
/// sites by aggregating per SiteTitle.
/// </summary>
private static List<StorageNode> CombineRecycleBins(List<StorageNode> input)
{
var byPath = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
var result = new List<StorageNode>(input.Count);
foreach (var node in input)
{
if (node.IndentLevel == 0 && node.Kind == StorageNodeKind.RecycleBin)
{
string key = node.SiteTitle ?? string.Empty;
if (!byPath.TryGetValue(key, out var agg))
{
agg = new StorageNode
{
Name = "[Recycle Bin] Total",
SiteTitle = node.SiteTitle ?? string.Empty,
Library = "RecycleBin",
Kind = StorageNodeKind.RecycleBin,
IndentLevel = 0,
Children = new List<StorageNode>()
};
byPath[key] = agg;
result.Add(agg);
}
agg.TotalSizeBytes += node.TotalSizeBytes;
agg.FileStreamSizeBytes += node.FileStreamSizeBytes;
agg.TotalFileCount += node.TotalFileCount;
if (node.LastModified.HasValue &&
(!agg.LastModified.HasValue || node.LastModified > agg.LastModified))
agg.LastModified = node.LastModified;
}
else
{
result.Add(node);
}
}
return result;
}
private bool IsKindShown(StorageNodeKind kind) => kind switch
{
StorageNodeKind.Library => ShowLibraries,
StorageNodeKind.HiddenLibrary => ShowHiddenLibraries,
StorageNodeKind.PreservationHold => ShowPreservationHold,
StorageNodeKind.ListAttachments => ShowListAttachments,
StorageNodeKind.RecycleBin => ShowRecycleBin,
StorageNodeKind.Subsite => ShowSubsites,
_ => true
};
protected override void OnTenantSwitched(TenantProfile profile) protected override void OnTenantSwitched(TenantProfile profile)
{ {
_currentProfile = profile; _currentProfile = profile;
_allNodes = new List<StorageNode>();
SpoReportedTotalSize = 0;
Results = new ObservableCollection<StorageNode>(); Results = new ObservableCollection<StorageNode>();
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(); FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
OnPropertyChanged(nameof(CurrentProfile)); OnPropertyChanged(nameof(CurrentProfile));
@@ -405,10 +557,6 @@ public partial class StorageViewModel : FeatureViewModelBase
private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30); 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); private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC);
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF); private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF);
private void ApplyChartThemeColors() private void ApplyChartThemeColors()
@@ -418,11 +566,6 @@ public partial class StorageViewModel : FeatureViewModelBase
TooltipTextPaint.Color = ChartFgColor; TooltipTextPaint.Color = ChartFgColor;
TooltipBackgroundPaint.Color = ChartSurfaceColor; TooltipBackgroundPaint.Color = ChartSurfaceColor;
} }
<<<<<<< HEAD
=======
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
private void UpdateChartSeries() 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;
}
}
@@ -96,3 +96,30 @@ public class ListToStringConverter : IValueConverter
=> throw new NotImplementedException(); => throw new NotImplementedException();
} }
/// <summary>
/// Converts a <see cref="SharepointToolbox.Core.Models.StorageNodeKind"/> enum
/// to a localized display string via the translation source.
/// </summary>
[ValueConversion(typeof(SharepointToolbox.Core.Models.StorageNodeKind), typeof(string))]
public class StorageKindConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not SharepointToolbox.Core.Models.StorageNodeKind kind) return string.Empty;
var T = SharepointToolbox.Localization.TranslationSource.Instance;
return kind switch
{
SharepointToolbox.Core.Models.StorageNodeKind.Library => T["stor.kind.library"],
SharepointToolbox.Core.Models.StorageNodeKind.HiddenLibrary => T["stor.kind.hidden"],
SharepointToolbox.Core.Models.StorageNodeKind.PreservationHold => T["stor.kind.preservation"],
SharepointToolbox.Core.Models.StorageNodeKind.ListAttachments => T["stor.kind.attachments"],
SharepointToolbox.Core.Models.StorageNodeKind.RecycleBin => T["stor.kind.recyclebin"],
SharepointToolbox.Core.Models.StorageNodeKind.Subsite => T["stor.kind.subsite"],
_ => kind.ToString()
};
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
@@ -16,17 +16,8 @@
<!-- Action bar: new folder (destination mode only) --> <!-- Action bar: new folder (destination mode only) -->
<StackPanel x:Name="ActionBar" DockPanel.Dock="Top" Orientation="Horizontal" <StackPanel x:Name="ActionBar" DockPanel.Dock="Top" Orientation="Horizontal"
Margin="0,0,0,6" Visibility="Collapsed"> Margin="0,0,0,6" Visibility="Collapsed">
<<<<<<< HEAD
<Button x:Name="NewFolderButton" <Button x:Name="NewFolderButton"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.new_folder]}" Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.new_folder]}"
=======
<<<<<<< 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
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Padding="8,3" Click="NewFolder_Click" IsEnabled="False" /> Padding="8,3" Click="NewFolder_Click" IsEnabled="False" />
</StackPanel> </StackPanel>
@@ -22,14 +22,7 @@ public partial class FolderBrowserDialog : Window
public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>(); public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>();
private readonly List<CheckBox> _fileCheckboxes = new(); private readonly List<CheckBox> _fileCheckboxes = new();
<<<<<<< HEAD
private readonly List<TreeViewItem> _expandedNodes = new(); private readonly List<TreeViewItem> _expandedNodes = new();
=======
<<<<<<< HEAD
private readonly List<TreeViewItem> _expandedNodes = new();
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
/// <summary> /// <summary>
/// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/> /// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/>
@@ -88,14 +81,7 @@ public partial class FolderBrowserDialog : Window
// Placeholder child so the expand arrow appears. // Placeholder child so the expand arrow appears.
node.Items.Add(new TreeViewItem { Header = "Loading..." }); node.Items.Add(new TreeViewItem { Header = "Loading..." });
node.Expanded += FolderNode_Expanded; node.Expanded += FolderNode_Expanded;
<<<<<<< HEAD
_expandedNodes.Add(node); _expandedNodes.Add(node);
=======
<<<<<<< HEAD
_expandedNodes.Add(node);
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
return node; return node;
} }
@@ -115,24 +101,9 @@ public partial class FolderBrowserDialog : Window
{ {
var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl); var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
_ctx.Load(folder, f => f.StorageMetrics.TotalSize, _ctx.Load(folder, f => f.StorageMetrics.TotalSize,
<<<<<<< HEAD
f => f.StorageMetrics.TotalFileCount); f => f.StorageMetrics.TotalFileCount);
var list = _ctx.Web.Lists.GetByTitle(info.LibraryTitle); var list = _ctx.Web.Lists.GetByTitle(info.LibraryTitle);
_ctx.Load(list, l => l.Title); _ctx.Load(list, l => l.Title);
=======
<<<<<<< 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
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
var progress = new Progress<Core.Models.OperationProgress>(); var progress = new Progress<Core.Models.OperationProgress>();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
@@ -143,10 +114,6 @@ public partial class FolderBrowserDialog : Window
folder.StorageMetrics.TotalFileCount, folder.StorageMetrics.TotalFileCount,
folder.StorageMetrics.TotalSize); folder.StorageMetrics.TotalSize);
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
// Enumerate direct children via paginated CAML — Folder.Folders / // Enumerate direct children via paginated CAML — Folder.Folders /
// Folder.Files lazy loading hits the list-view threshold on libraries // Folder.Files lazy loading hits the list-view threshold on libraries
// above 5,000 items even when only a small folder is being expanded. // above 5,000 items even when only a small folder is being expanded.
@@ -224,56 +191,6 @@ public partial class FolderBrowserDialog : Window
node.Items.Add(fileItem); node.Items.Add(fileItem);
} }
} }
<<<<<<< HEAD
=======
=======
// 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
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -388,10 +305,6 @@ public partial class FolderBrowserDialog : Window
Close(); Close();
} }
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
protected override void OnClosed(EventArgs e) protected override void OnClosed(EventArgs e)
{ {
Loaded -= OnLoaded; Loaded -= OnLoaded;
@@ -407,11 +320,6 @@ public partial class FolderBrowserDialog : Window
base.OnClosed(e); base.OnClosed(e);
} }
<<<<<<< HEAD
=======
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl); private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl);
private record FileNodeInfo(string LibraryTitle, string RelativePath); private record FileNodeInfo(string LibraryTitle, string RelativePath);
} }
@@ -1,17 +1,8 @@
<Window x:Class="SharepointToolbox.Views.Dialogs.InputDialog" <Window x:Class="SharepointToolbox.Views.Dialogs.InputDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
<<<<<<< HEAD
xmlns:loc="clr-namespace:SharepointToolbox.Localization" xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[input.title]}" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[input.title]}"
=======
<<<<<<< HEAD
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[input.title]}"
=======
Title="Input"
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Width="340" Height="140" Width="340" Height="140"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource AppBgBrush}" Background="{DynamicResource AppBgBrush}"
@@ -21,17 +12,8 @@
<DockPanel Margin="12"> <DockPanel Margin="12">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,10,0,0"> HorizontalAlignment="Right" Margin="0,10,0,0">
<<<<<<< HEAD
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Width="70" IsCancel="True" Margin="0,0,8,0" Width="70" IsCancel="True" Margin="0,0,8,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
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Click="Cancel_Click" /> Click="Cancel_Click" />
<Button Content="OK" Width="70" IsDefault="True" <Button Content="OK" Width="70" IsDefault="True"
Click="Ok_Click" /> 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" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Profile list --> <!-- Profile list -->
@@ -58,8 +59,31 @@
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid.hint]}" /> Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid.hint]}" />
</Grid> </Grid>
<!-- Profile CRUD buttons (placed under fields for natural flow) -->
<Grid Grid.Row="3" Margin="0,4,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add]}"
Command="{Binding AddCommand}"
MinWidth="90" Padding="10,4" Margin="0,0,6,0"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add.tooltip]}" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.save]}"
Command="{Binding SaveCommand}"
MinWidth="90" Padding="10,4"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.save.tooltip]}" />
</StackPanel>
<Button Grid.Column="1" Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete]}"
Command="{Binding DeleteCommand}"
MinWidth="90" Padding="10,4"
Foreground="{DynamicResource DangerBrush}"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete.tooltip]}" />
</Grid>
<!-- Client Logo --> <!-- Client Logo -->
<StackPanel Grid.Row="3" Margin="0,8,0,8"> <StackPanel Grid.Row="4" Margin="0,8,0,8">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.title]}" Padding="0,0,0,4" /> <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.title]}" Padding="0,0,0,4" />
<Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4" <Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4"
HorizontalAlignment="Left" MinWidth="200" MinHeight="50"> HorizontalAlignment="Left" MinWidth="200" MinHeight="50">
@@ -96,7 +120,7 @@
</StackPanel> </StackPanel>
<!-- App Registration --> <!-- App Registration -->
<StackPanel Grid.Row="4" Margin="0,8,0,8"> <StackPanel Grid.Row="5" Margin="0,8,0,8">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.title]}" <Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.title]}"
FontWeight="SemiBold" Padding="0,0,0,4" FontWeight="SemiBold" Padding="0,0,0,4"
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}" /> Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}" />
@@ -134,16 +158,10 @@
</Border> </Border>
</StackPanel> </StackPanel>
<!-- Buttons --> <!-- Close button -->
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right"> <StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add]}"
Command="{Binding AddCommand}" Width="60" Margin="4,0" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.save]}"
Command="{Binding SaveCommand}" MinWidth="80" Padding="6,0" Margin="4,0" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete]}"
Command="{Binding DeleteCommand}" Width="60" Margin="4,0" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.close]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[common.close]}"
Width="60" Margin="4,0" MinWidth="80" Padding="10,4"
Click="CloseButton_Click" IsCancel="True" /> Click="CloseButton_Click" IsCancel="True" />
</StackPanel> </StackPanel>
</Grid> </Grid>
@@ -9,6 +9,15 @@ public partial class ProfileManagementDialog : Window
{ {
InitializeComponent(); InitializeComponent();
DataContext = viewModel; DataContext = viewModel;
viewModel.ConfirmRegisterApp = msg =>
{
var result = MessageBox.Show(
this, msg,
Localization.TranslationSource.Instance["profile.register"],
MessageBoxButton.OKCancel,
MessageBoxImage.Information);
return result == MessageBoxResult.OK;
};
Loaded += async (_, _) => await viewModel.LoadAsync(); Loaded += async (_, _) => await viewModel.LoadAsync();
} }
@@ -60,17 +60,8 @@
<!-- Site list with checkboxes --> <!-- Site list with checkboxes -->
<ListView x:Name="SiteList" Grid.Row="2" Margin="0,0,0,8" <ListView x:Name="SiteList" Grid.Row="2" Margin="0,0,0,8"
SelectionMode="Single" SelectionMode="Single"
<<<<<<< HEAD
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}"
GridViewColumnHeader.Click="SiteList_ColumnHeaderClick"> GridViewColumnHeader.Click="SiteList_ColumnHeaderClick">
=======
<<<<<<< HEAD
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}"
GridViewColumnHeader.Click="SiteList_ColumnHeaderClick">
=======
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}">
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<ListView.View> <ListView.View>
<GridView> <GridView>
<GridViewColumn Width="32"> <GridViewColumn Width="32">
@@ -113,15 +104,7 @@
</ListView> </ListView>
<!-- Status text --> <!-- Status text -->
<<<<<<< HEAD
<TextBlock x:Name="StatusText" Grid.Row="3" Margin="0,0,0,8" <TextBlock x:Name="StatusText" Grid.Row="3" Margin="0,0,0,8"
=======
<<<<<<< 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
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
Foreground="{DynamicResource TextMutedBrush}" FontSize="11" /> Foreground="{DynamicResource TextMutedBrush}" FontSize="11" />
<!-- Button row --> <!-- Button row -->
@@ -44,10 +44,6 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" /> Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.label]}" Margin="0,4,0,0" /> <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"> <ComboBox SelectedIndex="{Binding SplitModeIndex}" Height="26" Margin="0,0,0,4">
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" /> <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.split.single]}" />
@@ -59,11 +55,6 @@
<ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.tabbed]}" /> <ComboBoxItem Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[export.html.layout.tabbed]}" />
</ComboBox> </ComboBox>
<<<<<<< HEAD
=======
=======
>>>>>>> f4cc81bb71b935c6f6f050288c9e283dcca5cfa8
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}"
Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" /> Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}"
@@ -92,24 +83,12 @@
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.size]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.size]}"
Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}" Binding="{Binding SizeBytes, Converter={StaticResource BytesConverter}}"
Width="90" ElementStyle="{StaticResource RightAlignStyle}" /> Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.created]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.created]}"
Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" /> Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.modified]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.modified]}"
Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" /> Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.path]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[report.col.path]}"
Binding="{Binding Path}" Width="400" /> Binding="{Binding Path}" Width="400" />
<<<<<<< HEAD
=======
=======
<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
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</DockPanel> </DockPanel>
+49 -1
View File
@@ -33,6 +33,43 @@
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
<!-- Scan sources group: control what the scan captures -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.sources]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.hidden]}"
IsChecked="{Binding ScanHiddenLibraries}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.preservation]}"
IsChecked="{Binding ScanPreservationHold}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.attachments]}"
IsChecked="{Binding ScanListAttachments}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.recyclebin]}"
IsChecked="{Binding ScanRecycleBin}" Margin="0,2" />
</StackPanel>
</GroupBox>
<!-- Report filter group: control what appears in DataGrid + exports -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.report.filter]}"
Margin="0,0,0,8">
<StackPanel Margin="4">
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.libraries]}"
IsChecked="{Binding ShowLibraries}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.hidden]}"
IsChecked="{Binding ShowHiddenLibraries}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.preservation]}"
IsChecked="{Binding ShowPreservationHold}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.attachments]}"
IsChecked="{Binding ShowListAttachments}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.recyclebin]}"
IsChecked="{Binding ShowRecycleBin}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.show.subsites]}"
IsChecked="{Binding ShowSubsites}" Margin="0,2" />
<Separator Margin="0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.combine.recyclebin]}"
IsChecked="{Binding CombineRecycleBinStages}" Margin="0,2" />
</StackPanel>
</GroupBox>
<!-- Action buttons --> <!-- Action buttons -->
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}" <Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.storage]}"
Command="{Binding RunCommand}" Command="{Binding RunCommand}"
@@ -112,10 +149,18 @@
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.version_size_colon]}" FontWeight="SemiBold" /> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.version_size_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SummaryVersionSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" /> <TextBlock Text="{Binding SummaryVersionSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.recyclebin_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SummaryRecycleBinSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,24,0">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.files_colon]}" FontWeight="SemiBold" /> <TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.files_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SummaryFileCount, StringFormat=N0, Mode=OneWay}" /> <TextBlock Text="{Binding SummaryFileCount, StringFormat=N0, Mode=OneWay}" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[storage.lbl.spo_reported_colon]}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SpoReportedTotalSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -140,6 +185,9 @@
</DataGridTemplateColumn> </DataGridTemplateColumn>
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.site]}"
Binding="{Binding SiteTitle}" Width="140" /> Binding="{Binding SiteTitle}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.kind]}"
Binding="{Binding Kind, Converter={StaticResource StorageKindConverter}}"
Width="120" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.files]}" <DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.col.files]}"
Binding="{Binding TotalFileCount, StringFormat=N0}" Binding="{Binding TotalFileCount, StringFormat=N0}"
Width="70" ElementStyle="{StaticResource RightAlignStyle}" /> Width="70" ElementStyle="{StaticResource RightAlignStyle}" />
@@ -18,10 +18,6 @@
Click="BrowseSource_Click" Margin="0,0,0,5" /> Click="BrowseSource_Click" Margin="0,0,0,5" />
<TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" /> <TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SourceFolderPath}" Foreground="{DynamicResource TextMutedBrush}" /> <TextBlock Text="{Binding SourceFolderPath}" Foreground="{DynamicResource TextMutedBrush}" />
<<<<<<< HEAD
=======
<<<<<<< HEAD
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11" <TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11"
Text="{Binding SelectedFileCount, Mode=OneWay}" /> Text="{Binding SelectedFileCount, Mode=OneWay}" />
@@ -36,23 +32,6 @@
IsChecked="{Binding CopyFolderContents}" IsChecked="{Binding CopyFolderContents}"
Margin="0,4,0,0" Margin="0,4,0,0"
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents.tooltip]}" /> ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.chk.copy_contents.tooltip]}" />
<<<<<<< HEAD
=======
=======
<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
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
</StackPanel> </StackPanel>
</GroupBox> </GroupBox>
@@ -44,15 +44,7 @@
</GroupBox.Style> </GroupBox.Style>
<StackPanel> <StackPanel>
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" /> <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="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.searching]}" FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" 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
>>>>>>> b8c09655ac1a3cf1e116d5b5178ec293659c1a72
<TextBlock.Style> <TextBlock.Style>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" /> <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;
};
}
}