using System.IO; using Microsoft.SharePoint.Client; using Serilog; using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; public class FileTransferService : IFileTransferService { public async Task> TransferAsync( ClientContext sourceCtx, ClientContext destCtx, TransferJob job, IProgress progress, CancellationToken ct) { // 1. Enumerate files from source progress.Report(new OperationProgress(0, 0, "Enumerating source files...")); var files = await EnumerateFilesAsync(sourceCtx, job, progress, ct); if (files.Count == 0) { progress.Report(new OperationProgress(0, 0, "No files found to transfer.")); return new BulkOperationSummary(new List>()); } // 2. Build source and destination base paths var srcBasePath = BuildServerRelativePath(sourceCtx, job.SourceLibrary, job.SourceFolderPath); var dstBasePath = BuildServerRelativePath(destCtx, job.DestinationLibrary, job.DestinationFolderPath); // 3. Transfer each file using BulkOperationRunner return await BulkOperationRunner.RunAsync( files, async (fileRelUrl, idx, token) => { // Compute destination path by replacing source base with dest base var relativePart = fileRelUrl; if (fileRelUrl.StartsWith(srcBasePath, StringComparison.OrdinalIgnoreCase)) relativePart = fileRelUrl.Substring(srcBasePath.Length).TrimStart('/'); // Ensure destination folder exists var destFolderRelative = dstBasePath; var fileFolder = Path.GetDirectoryName(relativePart)?.Replace('\\', '/'); if (!string.IsNullOrEmpty(fileFolder)) { destFolderRelative = $"{dstBasePath}/{fileFolder}"; await EnsureFolderAsync(destCtx, destFolderRelative, progress, token); } var fileName = Path.GetFileName(relativePart); var destFileUrl = $"{destFolderRelative}/{fileName}"; await TransferSingleFileAsync(sourceCtx, destCtx, fileRelUrl, destFileUrl, job, progress, token); Log.Information("Transferred: {Source} -> {Dest}", fileRelUrl, destFileUrl); }, progress, ct); } private async Task TransferSingleFileAsync( ClientContext sourceCtx, ClientContext destCtx, string srcFileUrl, string dstFileUrl, TransferJob job, IProgress progress, CancellationToken ct) { var srcPath = ResourcePath.FromDecodedUrl(srcFileUrl); var dstPath = ResourcePath.FromDecodedUrl(dstFileUrl); bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite; var options = new MoveCopyOptions { KeepBoth = job.ConflictPolicy == ConflictPolicy.Rename, ResetAuthorAndCreatedOnCopy = false, // best-effort metadata preservation }; try { if (job.Mode == TransferMode.Copy) { MoveCopyUtil.CopyFileByPath(sourceCtx, srcPath, dstPath, overwrite, options); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct); } else // Move { MoveCopyUtil.MoveFileByPath(sourceCtx, srcPath, dstPath, overwrite, options); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct); } } catch (ServerException ex) when (job.ConflictPolicy == ConflictPolicy.Skip && ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)) { Log.Warning("Skipped (already exists): {File}", srcFileUrl); } } private async Task> EnumerateFilesAsync( ClientContext ctx, TransferJob job, IProgress progress, CancellationToken ct) { var list = ctx.Web.Lists.GetByTitle(job.SourceLibrary); var rootFolder = list.RootFolder; ctx.Load(rootFolder, f => f.ServerRelativeUrl); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); var baseFolderUrl = rootFolder.ServerRelativeUrl.TrimEnd('/'); if (!string.IsNullOrEmpty(job.SourceFolderPath)) baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}"; var folder = ctx.Web.GetFolderByServerRelativeUrl(baseFolderUrl); var files = new List(); await CollectFilesRecursiveAsync(ctx, folder, files, progress, ct); return files; } private async Task CollectFilesRecursiveAsync( ClientContext ctx, Folder folder, List files, IProgress progress, CancellationToken ct) { ct.ThrowIfCancellationRequested(); ctx.Load(folder, f => f.Files.Include(fi => fi.ServerRelativeUrl), f => f.Folders); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); foreach (var file in folder.Files) { files.Add(file.ServerRelativeUrl); } foreach (var subFolder in folder.Folders) { // Skip system folders if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms") continue; await CollectFilesRecursiveAsync(ctx, subFolder, files, progress, ct); } } private async Task EnsureFolderAsync( ClientContext ctx, string folderServerRelativeUrl, IProgress progress, CancellationToken ct) { try { var folder = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl); ctx.Load(folder, f => f.Exists); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); if (folder.Exists) return; } catch { /* folder doesn't exist, create it */ } // Create folder using Folders.Add which creates intermediate folders ctx.Web.Folders.Add(folderServerRelativeUrl); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); } private static string BuildServerRelativePath(ClientContext ctx, string library, string folderPath) { // Extract site-relative URL from context URL var uri = new Uri(ctx.Url); var siteRelative = uri.AbsolutePath.TrimEnd('/'); var basePath = $"{siteRelative}/{library}"; if (!string.IsNullOrEmpty(folderPath)) basePath = $"{basePath}/{folderPath.TrimStart('/')}"; return basePath; } }