Added max list size circumvention for file transfers between sites.
This commit is contained in:
@@ -64,6 +64,7 @@ public class BulkMemberService : IBulkMemberService
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Graph API failed for {GroupUrl}, falling back to CSOM: {Error}",
|
||||
@@ -83,7 +84,7 @@ public class BulkMemberService : IBulkMemberService
|
||||
{
|
||||
// Resolve user by email
|
||||
var user = await graphClient.Users[email].GetAsync(cancellationToken: ct);
|
||||
if (user == null)
|
||||
if (user?.Id == null)
|
||||
throw new InvalidOperationException($"User not found: {email}");
|
||||
|
||||
var userRef = $"https://graph.microsoft.com/v1.0/directoryObjects/{user.Id}";
|
||||
@@ -138,13 +139,16 @@ public class BulkMemberService : IBulkMemberService
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* not a group-connected site */ }
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex) { Log.Debug("Group lookup not available for {SiteUrl}: {Error}", siteUrl, ex.Message); }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug("Could not resolve M365 group ID for {SiteUrl}: {Error}", siteUrl, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ public class BulkSiteService : IBulkSiteService
|
||||
var owners = ParseEmails(row.Owners);
|
||||
var members = ParseEmails(row.Members);
|
||||
|
||||
if (owners.Count == 0)
|
||||
throw new InvalidOperationException($"Team site '{row.Name}' requires at least one owner.");
|
||||
|
||||
var creationInfo = new TeamSiteCollectionCreationInformation
|
||||
{
|
||||
DisplayName = row.Name,
|
||||
@@ -88,6 +91,7 @@ public class BulkSiteService : IBulkSiteService
|
||||
membersGroup.Users.AddUser(user);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Failed to add member {Email} to {Site}: {Error}",
|
||||
@@ -142,6 +146,7 @@ public class BulkSiteService : IBulkSiteService
|
||||
ownersGroup.Users.AddUser(user);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Failed to add owner {Email} to {Site}: {Error}",
|
||||
@@ -162,6 +167,7 @@ public class BulkSiteService : IBulkSiteService
|
||||
membersGroup.Users.AddUser(user);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Failed to add member {Email} to {Site}: {Error}",
|
||||
|
||||
@@ -122,36 +122,46 @@ public class HtmlExportService
|
||||
AppendFilterInput(sb);
|
||||
AppendTableOpen(sb);
|
||||
sb.AppendLine("<thead><tr>");
|
||||
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
|
||||
sb.AppendLine($" <th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
|
||||
sb.AppendLine("</tr></thead>");
|
||||
sb.AppendLine("<tbody>");
|
||||
|
||||
int grpMemIdx = 0;
|
||||
foreach (var entry in entries)
|
||||
int sectionIdx = 0;
|
||||
var groups = entries.GroupBy(e => (e.ObjectType, e.Title, e.Url)).ToList();
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var typeCss = ObjectTypeCss(entry.ObjectType);
|
||||
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
||||
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
||||
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
|
||||
var sectionId = $"sec{sectionIdx++}";
|
||||
var first = group.First();
|
||||
var typeCss = ObjectTypeCss(group.Key.ObjectType);
|
||||
var uniqueCss = first.HasUniquePermissions ? "badge unique" : "badge inherited";
|
||||
var uniqueLbl = first.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
|
||||
var count = group.Count();
|
||||
|
||||
var (pills, subRows) = BuildUserPillsCell(
|
||||
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
|
||||
colSpan: 9, grpMemIdx: ref grpMemIdx,
|
||||
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
|
||||
hideSystemGroupRaw: hideSystemGroupRaw);
|
||||
|
||||
sb.AppendLine("<tr>");
|
||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
|
||||
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
|
||||
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
||||
sb.AppendLine($" <td>{pills}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
|
||||
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
|
||||
sb.AppendLine($" <td>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
|
||||
sb.AppendLine($"<tr class=\"section-header collapsed\" data-section=\"{sectionId}\">");
|
||||
sb.AppendLine($" <td colspan=\"5\"><span class=\"chevron\">▼</span><span class=\"{typeCss}\">{HtmlEncode(group.Key.ObjectType)}</span> <strong>{HtmlEncode(group.Key.Title)}</strong> <a href=\"{HtmlEncode(group.Key.Url)}\" target=\"_blank\">↗</a> <span class=\"{uniqueCss}\">{uniqueLbl}</span><span class=\"entry-badge\">{count} {T["report.text.entries_unit"]}</span></td>");
|
||||
sb.AppendLine("</tr>");
|
||||
if (subRows.Length > 0) sb.Append(subRows);
|
||||
|
||||
foreach (var entry in group)
|
||||
{
|
||||
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
|
||||
|
||||
var (pills, subRows) = BuildUserPillsCell(
|
||||
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
|
||||
colSpan: 5, grpMemIdx: ref grpMemIdx,
|
||||
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
|
||||
hideSystemGroupRaw: hideSystemGroupRaw,
|
||||
sectionId: sectionId);
|
||||
|
||||
sb.AppendLine($"<tr data-section-member=\"{sectionId}\" style=\"display:none\">");
|
||||
sb.AppendLine($" <td>{pills}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
|
||||
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
|
||||
sb.AppendLine($" <td>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
if (subRows.Length > 0) sb.Append(subRows);
|
||||
}
|
||||
}
|
||||
|
||||
AppendTableClose(sb);
|
||||
|
||||
@@ -52,17 +52,62 @@ a:hover { text-decoration: underline; }
|
||||
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
|
||||
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
|
||||
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
|
||||
.section-header td { background: #edf2f7; font-weight: 600; cursor: pointer; padding: 8px 14px; border-bottom: 2px solid #cbd5e0; user-select: none; }
|
||||
.section-header:hover td { background: #e2e8f0; }
|
||||
.section-header .chevron { margin-right: 8px; display: inline-block; transition: transform 0.15s; }
|
||||
.section-header.collapsed .chevron { transform: rotate(-90deg); }
|
||||
.entry-badge { display: inline-block; background: #e2e8f0; color: #4a5568; border-radius: 10px; padding: 1px 8px; font-size: .75rem; font-weight: 600; margin-left: 8px; }
|
||||
";
|
||||
|
||||
internal const string InlineJs = @"function filterTable() {
|
||||
var input = document.getElementById('filter').value.toLowerCase();
|
||||
var rows = document.querySelectorAll('#permTable tbody tr');
|
||||
rows.forEach(function(row) {
|
||||
if (row.hasAttribute('data-group')) return;
|
||||
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
|
||||
var sections = document.querySelectorAll('#permTable tbody tr.section-header');
|
||||
if (sections.length === 0) {
|
||||
document.querySelectorAll('#permTable tbody tr').forEach(function(row) {
|
||||
if (row.hasAttribute('data-group')) return;
|
||||
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!input) {
|
||||
sections.forEach(function(hdr) {
|
||||
hdr.style.display = '';
|
||||
var sid = hdr.getAttribute('data-section');
|
||||
var collapsed = hdr.classList.contains('collapsed');
|
||||
document.querySelectorAll('[data-section-member=' + sid + ']:not([data-group])').forEach(function(r) {
|
||||
r.style.display = collapsed ? 'none' : '';
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
sections.forEach(function(hdr) {
|
||||
var sid = hdr.getAttribute('data-section');
|
||||
var members = document.querySelectorAll('[data-section-member=' + sid + ']:not([data-group])');
|
||||
var anyMatch = false;
|
||||
members.forEach(function(r) {
|
||||
var match = r.textContent.toLowerCase().indexOf(input) > -1;
|
||||
r.style.display = match ? '' : 'none';
|
||||
if (match) anyMatch = true;
|
||||
});
|
||||
if (!anyMatch && hdr.textContent.toLowerCase().indexOf(input) > -1) {
|
||||
anyMatch = true;
|
||||
members.forEach(function(r) { r.style.display = ''; });
|
||||
}
|
||||
hdr.style.display = anyMatch ? '' : 'none';
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function(ev) {
|
||||
var hdr = ev.target.closest('.section-header');
|
||||
if (hdr) {
|
||||
var sid = hdr.getAttribute('data-section');
|
||||
hdr.classList.toggle('collapsed');
|
||||
var collapsed = hdr.classList.contains('collapsed');
|
||||
document.querySelectorAll('[data-section-member=' + sid + ']').forEach(function(r) {
|
||||
if (r.hasAttribute('data-group')) { r.style.display = 'none'; return; }
|
||||
r.style.display = collapsed ? 'none' : '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
var trigger = ev.target.closest('.group-expandable');
|
||||
if (!trigger) return;
|
||||
var id = trigger.getAttribute('data-group-target');
|
||||
@@ -141,7 +186,8 @@ document.addEventListener('click', function(ev) {
|
||||
ref int grpMemIdx,
|
||||
string? targetLabel = null,
|
||||
string? sharingLinkType = null,
|
||||
bool hideSystemGroupRaw = false)
|
||||
bool hideSystemGroupRaw = false,
|
||||
string? sectionId = null)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
@@ -173,35 +219,48 @@ document.addEventListener('click', function(ev) {
|
||||
|
||||
if (hasResolvedMembers && groupMembers!.TryGetValue(name, out var resolved))
|
||||
{
|
||||
var grpId = $"grpmem{grpMemIdx}";
|
||||
pills.Append("<span class=\"user-pill group-expandable\"");
|
||||
if (isResolvedSystemGroup)
|
||||
pills.Append(" data-system-group=\"1\"");
|
||||
pills.Append($" data-group-target=\"{HtmlEncode(grpId)}\" data-email=\"{HtmlEncode(login)}\">");
|
||||
if (isResolvedSystemGroup)
|
||||
if (resolved.Count == 0)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sharingLinkType))
|
||||
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
|
||||
pills.Append(HtmlEncode(targetLabel!));
|
||||
// Members unavailable — render plain pill, skip expandable sub-row.
|
||||
var cls2 = isResolvedSystemGroup ? "user-pill\" data-system-group=\"1" : "user-pill";
|
||||
pills.Append($"<span class=\"{cls2}\" title=\"{HtmlEncode(T["report.text.empty_group"])}\" data-email=\"{HtmlEncode(login)}\">");
|
||||
if (isResolvedSystemGroup)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sharingLinkType))
|
||||
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
|
||||
pills.Append(HtmlEncode(targetLabel!));
|
||||
}
|
||||
else
|
||||
{
|
||||
pills.Append(HtmlEncode(name));
|
||||
}
|
||||
pills.Append("</span>");
|
||||
}
|
||||
else
|
||||
{
|
||||
pills.Append(HtmlEncode(name));
|
||||
}
|
||||
pills.Append(" ▼</span>");
|
||||
var grpId = $"grpmem{grpMemIdx}";
|
||||
pills.Append("<span class=\"user-pill group-expandable\"");
|
||||
if (isResolvedSystemGroup)
|
||||
pills.Append(" data-system-group=\"1\"");
|
||||
pills.Append($" data-group-target=\"{HtmlEncode(grpId)}\" data-email=\"{HtmlEncode(login)}\">");
|
||||
if (isResolvedSystemGroup)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sharingLinkType))
|
||||
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
|
||||
pills.Append(HtmlEncode(targetLabel!));
|
||||
}
|
||||
else
|
||||
{
|
||||
pills.Append(HtmlEncode(name));
|
||||
}
|
||||
pills.Append(" ▼</span>");
|
||||
|
||||
string memberContent;
|
||||
if (resolved.Count > 0)
|
||||
{
|
||||
var parts = resolved.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
|
||||
memberContent = string.Join(" • ", parts);
|
||||
var memberContent = string.Join(" • ", parts);
|
||||
var sectionAttr = sectionId != null ? $" data-section-member=\"{HtmlEncode(sectionId)}\"" : "";
|
||||
subRows.AppendLine($"<tr data-group=\"{HtmlEncode(grpId)}\"{sectionAttr} style=\"display:none\"><td colspan=\"{colSpan}\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
||||
grpMemIdx++;
|
||||
}
|
||||
else
|
||||
{
|
||||
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
|
||||
}
|
||||
subRows.AppendLine($"<tr data-group=\"{HtmlEncode(grpId)}\" style=\"display:none\"><td colspan=\"{colSpan}\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
||||
grpMemIdx++;
|
||||
}
|
||||
else if (isResolvedSystemGroup)
|
||||
{
|
||||
|
||||
@@ -7,14 +7,19 @@ 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"/>.
|
||||
/// Orchestrates file copy/move between two SharePoint libraries (same or
|
||||
/// different tenants). Hybrid strategy: server-side <see cref="MoveCopyUtil"/>
|
||||
/// first (zero local bandwidth), then transparent fallback to stream copy
|
||||
/// (<c>OpenBinaryDirect</c>/<c>SaveBinaryDirect</c>) on a list-view-threshold
|
||||
/// failure so transfers still succeed against libraries above the 5,000-item
|
||||
/// cap. Folder enumeration uses paged CAML; folder creation is cached per job
|
||||
/// to avoid re-checking the same path for every file.
|
||||
/// </summary>
|
||||
public class FileTransferService : IFileTransferService
|
||||
{
|
||||
private const int ListViewThresholdItemCount = 5000;
|
||||
private const int LargeLibraryPageSize = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the configured <see cref="TransferJob"/>. Enumerates source files
|
||||
/// (unless the job is folder-only), pre-creates destination folders, then
|
||||
@@ -30,12 +35,30 @@ public class FileTransferService : IFileTransferService
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// 1. Enumerate files from source (unless contents are suppressed).
|
||||
// 1. Pre-flight: discover library item counts so we can pick a page size
|
||||
// for source enumeration and warn early that the server-side copy path
|
||||
// may trip the list-view threshold. The stream fallback in
|
||||
// TransferSingleFileAsync handles the LVT case transparently, but the
|
||||
// counts help size-tune enumeration up front.
|
||||
var srcItemCount = await TryGetListItemCountAsync(sourceCtx, job.SourceLibrary, progress, ct);
|
||||
var dstItemCount = await TryGetListItemCountAsync(destCtx, job.DestinationLibrary, progress, ct);
|
||||
Log.Information(
|
||||
"Transfer pre-flight: source={SrcLib} ({SrcCount} items), dest={DstLib} ({DstCount} items)",
|
||||
job.SourceLibrary, srcItemCount, job.DestinationLibrary, dstItemCount);
|
||||
|
||||
if (srcItemCount > ListViewThresholdItemCount || dstItemCount > ListViewThresholdItemCount)
|
||||
{
|
||||
progress.Report(OperationProgress.Indeterminate(
|
||||
$"Large library detected (source: {srcItemCount}, dest: {dstItemCount}). " +
|
||||
"Using paged enumeration and stream-copy fallback when needed."));
|
||||
}
|
||||
|
||||
// 2. 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);
|
||||
files = await EnumerateFilesAsync(sourceCtx, job, srcItemCount, progress, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -51,7 +74,7 @@ public class FileTransferService : IFileTransferService
|
||||
return new BulkOperationSummary<string>(new List<BulkItemResult<string>>());
|
||||
}
|
||||
|
||||
// 2. Build source and destination base paths. Resolve library roots via
|
||||
// 3. 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.
|
||||
@@ -60,6 +83,11 @@ public class FileTransferService : IFileTransferService
|
||||
var dstBasePath = await ResolveLibraryPathAsync(
|
||||
destCtx, job.DestinationLibrary, job.DestinationFolderPath, progress, ct);
|
||||
|
||||
// Per-job cache of destination folders we've already ensured. Without
|
||||
// this, EnsureFolderAsync re-checks .Exists for every file in the same
|
||||
// folder — thousands of round-trips on a flat directory transfer.
|
||||
var ensuredFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 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.
|
||||
@@ -74,11 +102,11 @@ public class FileTransferService : IFileTransferService
|
||||
if (!string.IsNullOrEmpty(srcFolderName))
|
||||
{
|
||||
dstBasePath = $"{dstBasePath}/{srcFolderName}";
|
||||
await EnsureFolderAsync(destCtx, dstBasePath, progress, ct);
|
||||
await EnsureFolderCachedAsync(destCtx, dstBasePath, ensuredFolders, progress, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Transfer each file using BulkOperationRunner
|
||||
// 4. Transfer each file using BulkOperationRunner
|
||||
return await BulkOperationRunner.RunAsync(
|
||||
files,
|
||||
async (fileRelUrl, idx, token) =>
|
||||
@@ -88,13 +116,13 @@ public class FileTransferService : IFileTransferService
|
||||
if (fileRelUrl.StartsWith(srcBasePath, StringComparison.OrdinalIgnoreCase))
|
||||
relativePart = fileRelUrl.Substring(srcBasePath.Length).TrimStart('/');
|
||||
|
||||
// Ensure destination folder exists
|
||||
// Ensure destination folder exists (cached)
|
||||
var destFolderRelative = dstBasePath;
|
||||
var fileFolder = Path.GetDirectoryName(relativePart)?.Replace('\\', '/');
|
||||
if (!string.IsNullOrEmpty(fileFolder))
|
||||
{
|
||||
destFolderRelative = $"{dstBasePath}/{fileFolder}";
|
||||
await EnsureFolderAsync(destCtx, destFolderRelative, progress, token);
|
||||
await EnsureFolderCachedAsync(destCtx, destFolderRelative, ensuredFolders, progress, token);
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(relativePart);
|
||||
@@ -116,6 +144,32 @@ public class FileTransferService : IFileTransferService
|
||||
TransferJob job,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Hybrid path: try the server-side MoveCopyUtil first (bytes never
|
||||
// leave SharePoint). If the destination (or source) library trips the
|
||||
// list-view threshold, fall back to a stream copy via HTTP-direct APIs
|
||||
// that bypass list internals.
|
||||
try
|
||||
{
|
||||
await ServerSideTransferAsync(sourceCtx, destCtx, srcFileUrl, dstFileUrl, job, progress, ct);
|
||||
}
|
||||
catch (ServerException ex) when (IsListViewThresholdException(ex))
|
||||
{
|
||||
Log.Warning(
|
||||
"Server-side transfer hit list-view threshold for {File} — falling back to stream copy.",
|
||||
srcFileUrl);
|
||||
await StreamTransferAsync(sourceCtx, destCtx, srcFileUrl, dstFileUrl, job, progress, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServerSideTransferAsync(
|
||||
ClientContext sourceCtx,
|
||||
ClientContext destCtx,
|
||||
string srcFileUrl,
|
||||
string dstFileUrl,
|
||||
TransferJob job,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// MoveCopyUtil.CopyFileByPath expects absolute URLs (scheme + host),
|
||||
// not server-relative paths. Passing "/sites/..." silently fails or
|
||||
@@ -153,9 +207,173 @@ public class FileTransferService : IFileTransferService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path-based stream copy fallback. Reads the source via
|
||||
/// <see cref="Microsoft.SharePoint.Client.File.OpenBinaryStream"/> and writes
|
||||
/// to the destination via <c>Folder.Files.Add(FileCreationInformation)</c>.
|
||||
/// Both target a specific folder by path rather than querying list items,
|
||||
/// so they succeed against libraries that exceed the list-view threshold.
|
||||
/// Bytes do round-trip through the local machine — this is strictly the
|
||||
/// fallback when server-side copy is unavailable.
|
||||
/// </summary>
|
||||
private async Task StreamTransferAsync(
|
||||
ClientContext sourceCtx,
|
||||
ClientContext destCtx,
|
||||
string srcFileUrl,
|
||||
string dstFileUrl,
|
||||
TransferJob job,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Resolve the destination file name for conflict handling. Returns null
|
||||
// when policy=Skip and the file already exists.
|
||||
var effectiveDestUrl = await ResolveDestinationOnConflictAsync(destCtx, dstFileUrl, job, progress, ct);
|
||||
if (effectiveDestUrl == null)
|
||||
{
|
||||
Log.Warning("Skipped (already exists, stream fallback): {File}", srcFileUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rename policy guarantees a free path via ResolveDestinationOnConflictAsync,
|
||||
// so overwrite is only needed for the explicit Overwrite policy.
|
||||
bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite;
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// 1. Download the source bytes into memory. OpenBinaryStream is a
|
||||
// ClientResult<Stream> — usable only after ExecuteQuery.
|
||||
var srcFile = sourceCtx.Web.GetFileByServerRelativeUrl(srcFileUrl);
|
||||
var streamResult = srcFile.OpenBinaryStream();
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct);
|
||||
|
||||
if (streamResult.Value == null)
|
||||
throw new InvalidOperationException($"Could not open binary stream for: {srcFileUrl}");
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await streamResult.Value.CopyToAsync(buffer, 81920, ct);
|
||||
buffer.Position = 0;
|
||||
|
||||
// 2. Upload to the destination folder. Files.Add with ContentStream
|
||||
// streams the payload in one request and does not touch list-view
|
||||
// metadata, so it bypasses LVT.
|
||||
var slash = effectiveDestUrl.LastIndexOf('/');
|
||||
if (slash < 0)
|
||||
throw new InvalidOperationException($"Invalid destination URL (no slash): {effectiveDestUrl}");
|
||||
var destFolderUrl = effectiveDestUrl.Substring(0, slash);
|
||||
var destFileName = effectiveDestUrl.Substring(slash + 1);
|
||||
|
||||
var destFolder = destCtx.Web.GetFolderByServerRelativeUrl(destFolderUrl);
|
||||
var creation = new FileCreationInformation
|
||||
{
|
||||
Url = destFileName,
|
||||
Overwrite = overwrite,
|
||||
ContentStream = buffer,
|
||||
};
|
||||
destFolder.Files.Add(creation);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(destCtx, progress, ct);
|
||||
|
||||
if (job.Mode == TransferMode.Move)
|
||||
{
|
||||
// Stream copy cannot atomically move; delete the source after a
|
||||
// successful upload to honour Move semantics.
|
||||
var srcDelete = sourceCtx.Web.GetFileByServerRelativeUrl(srcFileUrl);
|
||||
srcDelete.DeleteObject();
|
||||
try
|
||||
{
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex,
|
||||
"Move: source delete failed for {Src} after successful upload to {Dst}. " +
|
||||
"File is duplicated — manually delete the source.",
|
||||
srcFileUrl, effectiveDestUrl);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Honours <see cref="TransferJob.ConflictPolicy"/> when the destination
|
||||
/// path already exists. Returns the URL to write to, or <c>null</c> when
|
||||
/// the file should be skipped. For <see cref="ConflictPolicy.Rename"/>,
|
||||
/// probes <c>name (1).ext</c>, <c>name (2).ext</c>, ... until a free slot
|
||||
/// is found.
|
||||
/// </summary>
|
||||
private static async Task<string?> ResolveDestinationOnConflictAsync(
|
||||
ClientContext destCtx,
|
||||
string dstFileUrl,
|
||||
TransferJob job,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (job.ConflictPolicy == ConflictPolicy.Overwrite)
|
||||
return dstFileUrl;
|
||||
|
||||
bool exists = await FileExistsAsync(destCtx, dstFileUrl, progress, ct);
|
||||
if (!exists) return dstFileUrl;
|
||||
|
||||
if (job.ConflictPolicy == ConflictPolicy.Skip)
|
||||
return null;
|
||||
|
||||
// Rename: keep both. Append " (n)" before the extension.
|
||||
var dir = dstFileUrl.Substring(0, dstFileUrl.LastIndexOf('/'));
|
||||
var leaf = dstFileUrl.Substring(dstFileUrl.LastIndexOf('/') + 1);
|
||||
var stem = Path.GetFileNameWithoutExtension(leaf);
|
||||
var ext = Path.GetExtension(leaf);
|
||||
|
||||
for (int n = 1; n <= 999; n++)
|
||||
{
|
||||
var candidate = $"{dir}/{stem} ({n}){ext}";
|
||||
if (!await FileExistsAsync(destCtx, candidate, progress, ct))
|
||||
return candidate;
|
||||
}
|
||||
// Extremely unlikely; surface as failure rather than silent overwrite.
|
||||
throw new InvalidOperationException(
|
||||
$"Could not find an unused destination filename for {dstFileUrl} after 999 attempts.");
|
||||
}
|
||||
|
||||
private static async Task<bool> FileExistsAsync(
|
||||
ClientContext ctx,
|
||||
string fileServerRelativeUrl,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl);
|
||||
ctx.Load(file, f => f.Exists);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
return file.Exists;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug("File existence check failed for {Url}: {Error}", fileServerRelativeUrl, ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects SharePoint's list-view-threshold ServerException across locales.
|
||||
/// English: "exceeds the list view threshold". French: "depasse le seuil
|
||||
/// d'affichage de liste". German: "Listenansichtsschwellenwert".
|
||||
/// </summary>
|
||||
internal static bool IsListViewThresholdException(Exception ex)
|
||||
{
|
||||
var msg = ex.Message ?? string.Empty;
|
||||
return msg.Contains("list view threshold", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("seuil d'affichage", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("seuil d", StringComparison.OrdinalIgnoreCase) && msg.Contains("liste", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("Listenansichtsschwellenwert", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("umbral de vista de lista", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> EnumerateFilesAsync(
|
||||
ClientContext ctx,
|
||||
TransferJob job,
|
||||
int sourceItemCount,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -226,6 +444,45 @@ public class FileTransferService : IFileTransferService
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<int> TryGetListItemCountAsync(
|
||||
ClientContext ctx,
|
||||
string libraryTitle,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var list = ctx.Web.Lists.GetByTitle(libraryTitle);
|
||||
ctx.Load(list, l => l.ItemCount);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
return list.ItemCount;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal: pre-flight count is purely informational. Treat as
|
||||
// unknown (-1) so the rest of the pipeline still runs.
|
||||
Log.Warning("Failed to read ItemCount for {Library}: {Error}", libraryTitle, ex.Message);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EnsureFolderAsync wrapper that records successful checks in a per-job
|
||||
/// set so the same destination folder isn't re-validated for every file.
|
||||
/// </summary>
|
||||
private async Task EnsureFolderCachedAsync(
|
||||
ClientContext ctx,
|
||||
string folderServerRelativeUrl,
|
||||
HashSet<string> cache,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var normalized = folderServerRelativeUrl.TrimEnd('/');
|
||||
if (!cache.Add(normalized)) return;
|
||||
await EnsureFolderAsync(ctx, normalized, progress, ct);
|
||||
}
|
||||
|
||||
private async Task EnsureFolderAsync(
|
||||
ClientContext ctx,
|
||||
string folderServerRelativeUrl,
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace SharepointToolbox.Services;
|
||||
/// </summary>
|
||||
public class SharePointGroupResolver : ISharePointGroupResolver
|
||||
{
|
||||
private readonly AppGraphClientFactory? _graphClientFactory;
|
||||
private readonly AppGraphClientFactory _graphClientFactory;
|
||||
|
||||
public SharePointGroupResolver(AppGraphClientFactory graphClientFactory)
|
||||
{
|
||||
@@ -57,6 +57,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
||||
foreach (var g in ctx.Web.SiteGroups)
|
||||
groupTitles.Add(g.Title);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Could not enumerate SiteGroups on {Url}: {Error}", ctx.Url, ex.Message);
|
||||
@@ -92,7 +93,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
||||
if (IsAadGroup(user.LoginName))
|
||||
{
|
||||
// Lazy-create graph client on first AAD group encountered
|
||||
graphClient ??= await _graphClientFactory!.CreateClientAsync(clientId, ct);
|
||||
graphClient ??= await _graphClientFactory.CreateClientAsync(clientId, ct);
|
||||
|
||||
var aadId = ExtractAadGroupId(user.LoginName);
|
||||
var leafUsers = await ResolveAadGroupAsync(graphClient, aadId, ct);
|
||||
@@ -110,6 +111,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
||||
.DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Could not resolve SP group '{Group}': {Error}", groupName, ex.Message);
|
||||
@@ -182,6 +184,7 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
||||
await pageIterator.IterateAsync(ct);
|
||||
return members;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning("Could not resolve AAD group '{Id}' transitively: {Error}", aadGroupId, ex.Message);
|
||||
|
||||
@@ -42,6 +42,7 @@ public class SystemGroupTargetResolver : ISystemGroupTargetResolver
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug("System group target resolution failed for {Kind} on {Site}: {Error}",
|
||||
@@ -97,7 +98,7 @@ public class SystemGroupTargetResolver : ISystemGroupTargetResolver
|
||||
return new SystemGroupTarget(SystemGroupKind.SharingLink, file.Name, url, linkType);
|
||||
}
|
||||
}
|
||||
catch (ServerException) { /* fall through */ }
|
||||
catch (ServerException ex) { Log.Debug("File by ID not found for {Id} on {Site}: {Error}", itemUniqueId, ctx.Url, ex.Message); }
|
||||
|
||||
// 2. Try as folder on current web.
|
||||
try
|
||||
@@ -109,7 +110,7 @@ public class SystemGroupTargetResolver : ISystemGroupTargetResolver
|
||||
var url = BuildAbsoluteUrl(ctx.Url, folder.ServerRelativeUrl);
|
||||
return new SystemGroupTarget(SystemGroupKind.SharingLink, folder.Name, url, linkType);
|
||||
}
|
||||
catch (ServerException) { /* fall through */ }
|
||||
catch (ServerException ex) { Log.Debug("Folder by ID not found for {Id} on {Site}: {Error}", itemUniqueId, ctx.Url, ex.Message); }
|
||||
|
||||
// 3. Search-index fallback — covers items moved to a different subsite or
|
||||
// deleted recently (the index may lag the deletion by minutes/hours).
|
||||
@@ -168,6 +169,7 @@ public class SystemGroupTargetResolver : ISystemGroupTargetResolver
|
||||
path,
|
||||
linkType);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug("UniqueId search fallback failed for {Item} on {Site}: {Error}",
|
||||
|
||||
@@ -117,6 +117,7 @@ public class VersionCleanupService : IVersionCleanupService
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
int before = 0;
|
||||
try
|
||||
{
|
||||
var file = ctx.Web.GetFileByServerRelativeUrl(fileServerRelativeUrl);
|
||||
@@ -131,7 +132,7 @@ public class VersionCleanupService : IVersionCleanupService
|
||||
// file.Versions contains only HISTORICAL versions; the current published
|
||||
// version lives on `file` itself and is never deletable here.
|
||||
var versions = file.Versions.ToList();
|
||||
int before = versions.Count;
|
||||
before = versions.Count;
|
||||
if (before == 0) return null;
|
||||
|
||||
// Sort by Created ascending so [0] is the oldest historical version.
|
||||
@@ -173,6 +174,7 @@ public class VersionCleanupService : IVersionCleanupService
|
||||
BytesFreed = bytesFreed,
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to trim versions for {File}", fileServerRelativeUrl);
|
||||
@@ -182,6 +184,7 @@ public class VersionCleanupService : IVersionCleanupService
|
||||
Library = libraryTitle,
|
||||
FileServerRelativeUrl = fileServerRelativeUrl,
|
||||
FileName = System.IO.Path.GetFileName(fileServerRelativeUrl),
|
||||
VersionsBefore = before,
|
||||
Error = ex.Message,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user