using Microsoft.Extensions.Logging; using Microsoft.SharePoint.Client; using SharepointToolbox.Web.Core.Helpers; using SharepointToolbox.Web.Core.Models; using SharepointToolbox.Web.Services.Audit; namespace SharepointToolbox.Web.Services; public class VersionCleanupService : IVersionCleanupService { private readonly ILogger _logger; private readonly IAuditService _audit; public VersionCleanupService(ILogger logger, IAuditService audit) { _logger = logger; _audit = audit; } 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(); string 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})")); 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); } } var totalDeleted = results.Sum(r => r.VersionsDeleted); await _audit.LogAsync("VersionCleanup", siteUrl, new[] { siteUrl }, $"{totalDeleted} versions deleted across {results.Count} files"); return results; } private async Task TrimFileVersionsAsync(ClientContext ctx, string siteUrl, string libraryTitle, string fileServerRelativeUrl, VersionCleanupOptions options, IProgress progress, CancellationToken ct) { int before = 0; 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); var versions = file.Versions.ToList(); before = versions.Count; if (before == 0) return null; var ordered = versions.OrderBy(v => v.Created).ToList(); 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; bytesFreed += ordered[i].Size; ordered[i].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 (OperationCanceledException) { throw; } 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), VersionsBefore = before, Error = ex.Message }; } } }