This commit is contained in:
Dev
2026-04-24 10:54:47 +02:00
19 changed files with 1113 additions and 51 deletions
@@ -0,0 +1,28 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface IVersionCleanupService
{
/// <summary>
/// Enumerates document libraries (filtered by <see cref="VersionCleanupOptions.LibraryTitles"/>
/// when non-empty) and deletes historical file versions per file according to
/// <see cref="VersionCleanupOptions.KeepLast"/> and <see cref="VersionCleanupOptions.KeepFirst"/>.
/// The current published version is never touched. Returns one result row per file
/// where at least one version was inspected.
/// </summary>
Task<IReadOnlyList<VersionCleanupResult>> DeleteOldVersionsAsync(
ClientContext ctx,
VersionCleanupOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
/// <summary>
/// Lists non-hidden document libraries on the site. Used by the library picker
/// so callers can present a checkbox UI.
/// </summary>
Task<IReadOnlyList<string>> ListLibraryTitlesAsync(
ClientContext ctx,
CancellationToken ct);
}
@@ -0,0 +1,189 @@
using Microsoft.Extensions.Logging;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public class VersionCleanupService : IVersionCleanupService
{
private readonly ILogger<VersionCleanupService> _logger;
public VersionCleanupService(ILogger<VersionCleanupService> logger)
{
_logger = logger;
}
public async Task<IReadOnlyList<string>> ListLibraryTitlesAsync(
ClientContext ctx,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
ctx.Load(ctx.Web,
w => w.Lists.Include(
l => l.Title,
l => l.Hidden,
l => l.BaseType));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
return ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.Select(l => l.Title)
.OrderBy(t => t, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public async Task<IReadOnlyList<VersionCleanupResult>> DeleteOldVersionsAsync(
ClientContext ctx,
VersionCleanupOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (options.KeepLast < 0)
throw new ArgumentOutOfRangeException(nameof(options), "KeepLast must be >= 0.");
ctx.Load(ctx.Web, w => w.Url, w => w.ServerRelativeUrl,
w => w.Lists.Include(
l => l.Title,
l => l.Hidden,
l => l.BaseType,
l => l.RootFolder.ServerRelativeUrl));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var allLibs = ctx.Web.Lists
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
.ToList();
var titleFilter = options.LibraryTitles?.Count > 0
? new HashSet<string>(options.LibraryTitles, StringComparer.OrdinalIgnoreCase)
: null;
var libs = titleFilter is null
? allLibs
: allLibs.Where(l => titleFilter.Contains(l.Title)).ToList();
var results = new List<VersionCleanupResult>();
var siteUrl = ctx.Web.Url;
int libIdx = 0;
foreach (var lib in libs)
{
ct.ThrowIfCancellationRequested();
libIdx++;
progress.Report(new OperationProgress(libIdx, libs.Count,
$"Scanning versions: {lib.Title} ({libIdx}/{libs.Count})"));
// Enumerate files via paginated CAML so libs > 5,000 items work.
var files = new List<string>();
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
ctx, lib, lib.RootFolder.ServerRelativeUrl, recursive: true,
viewFields: new[] { "FSObjType", "FileRef" },
ct: ct))
{
if (item["FSObjType"]?.ToString() != "0") continue;
var fileRef = item["FileRef"]?.ToString();
if (!string.IsNullOrEmpty(fileRef))
files.Add(fileRef);
}
int fileIdx = 0;
foreach (var fileRef in files)
{
ct.ThrowIfCancellationRequested();
fileIdx++;
if (fileIdx % 25 == 0 || fileIdx == files.Count)
{
progress.Report(new OperationProgress(fileIdx, files.Count,
$"{lib.Title}: {fileIdx}/{files.Count} files"));
}
var result = await TrimFileVersionsAsync(
ctx, siteUrl, lib.Title, fileRef, options, progress, ct);
if (result is not null)
results.Add(result);
}
}
return results;
}
private async Task<VersionCleanupResult?> TrimFileVersionsAsync(
ClientContext ctx,
string siteUrl,
string libraryTitle,
string fileServerRelativeUrl,
VersionCleanupOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
try
{
var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl);
ctx.Load(file, f => f.Name);
ctx.Load(file.Versions,
vs => vs.Include(
v => v.VersionLabel,
v => v.Created,
v => v.Size));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
// file.Versions contains only HISTORICAL versions; the current published
// version lives on `file` itself and is never deletable here.
var versions = file.Versions.ToList();
int before = versions.Count;
if (before == 0) return null;
// Sort by Created ascending so [0] is the oldest historical version.
var ordered = versions
.OrderBy(v => v.Created)
.ToList();
// Preserve set: the last N most recent + optionally the very first.
var keep = new HashSet<int>();
int keepLast = Math.Min(options.KeepLast, ordered.Count);
for (int i = ordered.Count - keepLast; i < ordered.Count; i++)
keep.Add(i);
if (options.KeepFirst && ordered.Count > 0)
keep.Add(0);
long bytesFreed = 0;
int deleted = 0;
for (int i = 0; i < ordered.Count; i++)
{
if (keep.Contains(i)) continue;
var v = ordered[i];
bytesFreed += v.Size;
v.DeleteObject();
deleted++;
}
if (deleted == 0) return null;
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
return new VersionCleanupResult
{
SiteUrl = siteUrl,
Library = libraryTitle,
FileServerRelativeUrl = fileServerRelativeUrl,
FileName = System.IO.Path.GetFileName(fileServerRelativeUrl),
VersionsBefore = before,
VersionsDeleted = deleted,
VersionsRemaining = before - deleted,
BytesFreed = bytesFreed,
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to trim versions for {File}", fileServerRelativeUrl);
return new VersionCleanupResult
{
SiteUrl = siteUrl,
Library = libraryTitle,
FileServerRelativeUrl = fileServerRelativeUrl,
FileName = System.IO.Path.GetFileName(fileServerRelativeUrl),
Error = ex.Message,
};
}
}
}