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:
Dev
2026-04-20 11:23:11 +02:00
parent 8f30a60d2a
commit 12dd1de9f2
93 changed files with 8708 additions and 1159 deletions
+167 -45
View File
@@ -6,8 +6,23 @@ using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
/// <summary>
/// Orchestrates server-side file copy/move between two SharePoint libraries
/// (same or different tenants). Uses <see cref="MoveCopyUtil"/> for the
/// transfer itself so bytes never round-trip through the local machine.
/// Folder creation and enumeration are done via CSOM; all ambient retries
/// flow through <see cref="ExecuteQueryRetryHelper"/>.
/// </summary>
public class FileTransferService : IFileTransferService
{
/// <summary>
/// Runs the configured <see cref="TransferJob"/>. Enumerates source files
/// (unless the job is folder-only), pre-creates destination folders, then
/// copies or moves each file according to <see cref="TransferJob.Mode"/>
/// and <see cref="TransferJob.ConflictPolicy"/>. Returns a per-item
/// summary where failures are reported individually — the method does
/// not abort on first error so partial transfers are recoverable.
/// </summary>
public async Task<BulkOperationSummary<string>> TransferAsync(
ClientContext sourceCtx,
ClientContext destCtx,
@@ -15,19 +30,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 +117,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,41 +164,66 @@ 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('/')}";
var folder = ctx.Web.GetFolderByServerRelativeUrl(baseFolderUrl);
// Paginated recursive CAML query — Folder.Files / Folder.Folders lazy
// loading hits the list-view threshold on libraries > 5,000 items.
var files = new List<string>();
await CollectFilesRecursiveAsync(ctx, folder, files, progress, ct);
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
ctx, list, baseFolderUrl, recursive: true,
viewFields: new[] { "FSObjType", "FileRef", "FileDirRef" },
ct: ct))
{
ct.ThrowIfCancellationRequested();
if (item["FSObjType"]?.ToString() != "0") continue; // files only
var fileRef = item["FileRef"]?.ToString();
if (string.IsNullOrEmpty(fileRef)) continue;
// Skip files under SharePoint system folders (e.g. "Forms", "_*").
var dir = item["FileDirRef"]?.ToString() ?? string.Empty;
if (HasSystemFolderSegment(dir, baseFolderUrl)) continue;
files.Add(fileRef);
}
return files;
}
private async Task CollectFilesRecursiveAsync(
ClientContext ctx,
Folder folder,
List<string> files,
IProgress<OperationProgress> progress,
CancellationToken ct)
private static bool HasSystemFolderSegment(string fileDirRef, string baseFolderUrl)
{
ct.ThrowIfCancellationRequested();
if (string.IsNullOrEmpty(fileDirRef)) return false;
var baseTrim = baseFolderUrl.TrimEnd('/');
if (!fileDirRef.StartsWith(baseTrim, StringComparison.OrdinalIgnoreCase))
return false;
ctx.Load(folder, f => f.Files.Include(fi => fi.ServerRelativeUrl),
f => f.Folders);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var tail = fileDirRef.Substring(baseTrim.Length).Trim('/');
if (string.IsNullOrEmpty(tail)) return false;
foreach (var file in folder.Files)
foreach (var seg in tail.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
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);
if (seg.StartsWith("_", StringComparison.Ordinal) ||
seg.Equals("Forms", StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private async Task EnsureFolderAsync(
@@ -152,28 +232,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;
}
}