feat(04-03): implement FileTransferService with MoveCopyUtil and conflict policies

- FileTransferService.cs: CSOM copy/move via MoveCopyUtil.CopyFileByPath/MoveFileByPath
- Conflict policies: Skip (catch ServerException), Overwrite (overwrite=true), Rename (KeepBoth=true)
- ResourcePath.FromDecodedUrl for special character support
- Recursive folder enumeration with system folder filtering
- EnsureFolderAsync creates intermediate destination folders
- Best-effort metadata preservation (ResetAuthorAndCreatedOnCopy=false)
- FileTransferServiceTests.cs: 4 passing tests, 3 skipped (integration)
This commit is contained in:
Dev
2026-04-03 10:02:57 +02:00
parent b0956adaa3
commit ac74d31933
4 changed files with 363 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
using Microsoft.Graph;
using Microsoft.Identity.Client;
using Microsoft.Kiota.Abstractions.Authentication;
namespace SharepointToolbox.Infrastructure.Auth;
public class GraphClientFactory
{
private readonly MsalClientFactory _msalFactory;
public GraphClientFactory(MsalClientFactory msalFactory)
{
_msalFactory = msalFactory;
}
/// <summary>
/// Creates a GraphServiceClient that acquires tokens via the same MSAL PCA
/// used for SharePoint auth, but with Graph scopes.
/// </summary>
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct)
{
var pca = await _msalFactory.GetOrCreateAsync(clientId);
var accounts = await pca.GetAccountsAsync();
var account = accounts.FirstOrDefault();
var graphScopes = new[] { "https://graph.microsoft.com/.default" };
var tokenProvider = new MsalTokenProvider(pca, account, graphScopes);
var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
return new GraphServiceClient(authProvider);
}
}
/// <summary>
/// Bridges MSAL PCA token acquisition with Graph SDK's IAccessTokenProvider interface.
/// </summary>
internal class MsalTokenProvider : IAccessTokenProvider
{
private readonly IPublicClientApplication _pca;
private readonly IAccount? _account;
private readonly string[] _scopes;
public MsalTokenProvider(IPublicClientApplication pca, IAccount? account, string[] scopes)
{
_pca = pca;
_account = account;
_scopes = scopes;
}
public AllowedHostsValidator AllowedHostsValidator { get; } = new();
public async Task<string> GetAuthorizationTokenAsync(
Uri uri,
Dictionary<string, object>? additionalAuthenticationContext = null,
CancellationToken cancellationToken = default)
{
try
{
var result = await _pca.AcquireTokenSilent(_scopes, _account)
.ExecuteAsync(cancellationToken);
return result.AccessToken;
}
catch (MsalUiRequiredException)
{
// If silent fails, try interactive
var result = await _pca.AcquireTokenInteractive(_scopes)
.ExecuteAsync(cancellationToken);
return result.AccessToken;
}
}
}

View File

@@ -0,0 +1,179 @@
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<BulkOperationSummary<string>> TransferAsync(
ClientContext sourceCtx,
ClientContext destCtx,
TransferJob job,
IProgress<OperationProgress> 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<string>(new List<BulkItemResult<string>>());
}
// 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<OperationProgress> 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<IReadOnlyList<string>> EnumerateFilesAsync(
ClientContext ctx,
TransferJob job,
IProgress<OperationProgress> 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<string>();
await CollectFilesRecursiveAsync(ctx, folder, files, progress, ct);
return files;
}
private async Task CollectFilesRecursiveAsync(
ClientContext ctx,
Folder folder,
List<string> files,
IProgress<OperationProgress> 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<OperationProgress> 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;
}
}