chore: release v2.4
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,19 +15,53 @@ public class FileTransferService : IFileTransferService
|
||||
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);
|
||||
// 1. Enumerate files from source (unless contents are suppressed).
|
||||
IReadOnlyList<string> files;
|
||||
if (job.CopyFolderContents)
|
||||
{
|
||||
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
|
||||
files = await EnumerateFilesAsync(sourceCtx, job, progress, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
files = Array.Empty<string>();
|
||||
}
|
||||
|
||||
if (files.Count == 0)
|
||||
// When CopyFolderContents is off, the job is folder-only: ensure the
|
||||
// destination folder is created below (IncludeSourceFolder branch) and
|
||||
// return without iterating any files.
|
||||
if (files.Count == 0 && !job.IncludeSourceFolder)
|
||||
{
|
||||
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);
|
||||
// 2. Build source and destination base paths. Resolve library roots via
|
||||
// CSOM — constructing from title breaks for localized libraries whose
|
||||
// URL segment differs (e.g. title "Documents" → URL "Shared Documents"),
|
||||
// causing "Access denied" when CSOM tries to touch a non-existent path.
|
||||
var srcBasePath = await ResolveLibraryPathAsync(
|
||||
sourceCtx, job.SourceLibrary, job.SourceFolderPath, progress, ct);
|
||||
var dstBasePath = await ResolveLibraryPathAsync(
|
||||
destCtx, job.DestinationLibrary, job.DestinationFolderPath, progress, ct);
|
||||
|
||||
// When IncludeSourceFolder is set, recreate the source folder name under
|
||||
// destination so dest/srcFolderName/... mirrors the source tree. When
|
||||
// no SourceFolderPath is set, fall back to the source library name.
|
||||
// Also pre-create the folder itself — per-file EnsureFolder only fires
|
||||
// for nested paths, so flat files at the root of the source folder
|
||||
// would otherwise copy into a missing parent and fail.
|
||||
if (job.IncludeSourceFolder)
|
||||
{
|
||||
var srcFolderName = !string.IsNullOrEmpty(job.SourceFolderPath)
|
||||
? Path.GetFileName(job.SourceFolderPath.TrimEnd('/'))
|
||||
: job.SourceLibrary;
|
||||
if (!string.IsNullOrEmpty(srcFolderName))
|
||||
{
|
||||
dstBasePath = $"{dstBasePath}/{srcFolderName}";
|
||||
await EnsureFolderAsync(destCtx, dstBasePath, progress, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Transfer each file using BulkOperationRunner
|
||||
return await BulkOperationRunner.RunAsync(
|
||||
@@ -68,8 +102,14 @@ public class FileTransferService : IFileTransferService
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var srcPath = ResourcePath.FromDecodedUrl(srcFileUrl);
|
||||
var dstPath = ResourcePath.FromDecodedUrl(dstFileUrl);
|
||||
// MoveCopyUtil.CopyFileByPath expects absolute URLs (scheme + host),
|
||||
// not server-relative paths. Passing "/sites/..." silently fails or
|
||||
// returns no error yet copies nothing — especially across site
|
||||
// collections. Prefix with the owning site's scheme+host.
|
||||
var srcAbs = ToAbsoluteUrl(sourceCtx, srcFileUrl);
|
||||
var dstAbs = ToAbsoluteUrl(destCtx, dstFileUrl);
|
||||
var srcPath = ResourcePath.FromDecodedUrl(srcAbs);
|
||||
var dstPath = ResourcePath.FromDecodedUrl(dstAbs);
|
||||
|
||||
bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite;
|
||||
var options = new MoveCopyOptions
|
||||
@@ -109,7 +149,20 @@ public class FileTransferService : IFileTransferService
|
||||
ctx.Load(rootFolder, f => f.ServerRelativeUrl);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
var baseFolderUrl = rootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||
var libraryRoot = rootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||
|
||||
// Explicit per-file selection overrides folder enumeration. Paths are
|
||||
// library-relative (e.g. "SubFolder/file.docx") and get resolved to
|
||||
// full server-relative URLs here.
|
||||
if (job.SelectedFilePaths.Count > 0)
|
||||
{
|
||||
return job.SelectedFilePaths
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => $"{libraryRoot}/{p.TrimStart('/')}")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var baseFolderUrl = libraryRoot;
|
||||
if (!string.IsNullOrEmpty(job.SourceFolderPath))
|
||||
baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}";
|
||||
|
||||
@@ -152,28 +205,70 @@ public class FileTransferService : IFileTransferService
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
folderServerRelativeUrl = folderServerRelativeUrl.TrimEnd('/');
|
||||
|
||||
// Already there?
|
||||
try
|
||||
{
|
||||
var folder = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl);
|
||||
ctx.Load(folder, f => f.Exists);
|
||||
var existing = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl);
|
||||
ctx.Load(existing, f => f.Exists);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
if (folder.Exists) return;
|
||||
if (existing.Exists) return;
|
||||
}
|
||||
catch { /* folder doesn't exist, create it */ }
|
||||
catch { /* not present — fall through to creation */ }
|
||||
|
||||
// Create folder using Folders.Add which creates intermediate folders
|
||||
ctx.Web.Folders.Add(folderServerRelativeUrl);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
// Walk the path, creating each missing segment. `Web.Folders.Add(url)` is
|
||||
// ambiguous across CSOM versions (some treat the arg as relative to Web,
|
||||
// others server-relative), which produces bogus paths + "Access denied".
|
||||
// Resolve the parent explicitly and add only the leaf name instead.
|
||||
int slash = folderServerRelativeUrl.LastIndexOf('/');
|
||||
if (slash <= 0) return;
|
||||
|
||||
var parentUrl = folderServerRelativeUrl.Substring(0, slash);
|
||||
var leafName = folderServerRelativeUrl.Substring(slash + 1);
|
||||
if (string.IsNullOrEmpty(leafName)) return;
|
||||
|
||||
// Recurse to guarantee the parent exists first.
|
||||
await EnsureFolderAsync(ctx, parentUrl, progress, ct);
|
||||
|
||||
var parent = ctx.Web.GetFolderByServerRelativeUrl(parentUrl);
|
||||
parent.Folders.Add(leafName);
|
||||
try
|
||||
{
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("EnsureFolder failed at {Parent}/{Leaf}: {Error}",
|
||||
parentUrl, leafName, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildServerRelativePath(ClientContext ctx, string library, string folderPath)
|
||||
private static string ToAbsoluteUrl(ClientContext ctx, string pathOrUrl)
|
||||
{
|
||||
// Extract site-relative URL from context URL
|
||||
if (pathOrUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
pathOrUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return pathOrUrl;
|
||||
|
||||
var uri = new Uri(ctx.Url);
|
||||
var siteRelative = uri.AbsolutePath.TrimEnd('/');
|
||||
var basePath = $"{siteRelative}/{library}";
|
||||
if (!string.IsNullOrEmpty(folderPath))
|
||||
basePath = $"{basePath}/{folderPath.TrimStart('/')}";
|
||||
return $"{uri.Scheme}://{uri.Host}{(pathOrUrl.StartsWith("/") ? "" : "/")}{pathOrUrl}";
|
||||
}
|
||||
|
||||
private static async Task<string> ResolveLibraryPathAsync(
|
||||
ClientContext ctx,
|
||||
string libraryTitle,
|
||||
string relativeFolderPath,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var list = ctx.Web.Lists.GetByTitle(libraryTitle);
|
||||
ctx.Load(list, l => l.RootFolder.ServerRelativeUrl);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
var basePath = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||
if (!string.IsNullOrEmpty(relativeFolderPath))
|
||||
basePath = $"{basePath}/{relativeFolderPath.TrimStart('/')}";
|
||||
return basePath;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user