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 _logger; public VersionCleanupService(ILogger logger) { _logger = logger; } public async Task> 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> DeleteOldVersionsAsync( ClientContext ctx, VersionCleanupOptions options, IProgress 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(options.LibraryTitles, StringComparer.OrdinalIgnoreCase) : null; var libs = titleFilter is null ? allLibs : allLibs.Where(l => titleFilter.Contains(l.Title)).ToList(); var results = new List(); 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(); 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 TrimFileVersionsAsync( ClientContext ctx, string siteUrl, string libraryTitle, string fileServerRelativeUrl, VersionCleanupOptions options, IProgress 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 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, }; } } }