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:
@@ -1,7 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.SharePoint.Client;
|
||||
using Microsoft.SharePoint.Client.Search.Query;
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Services.Export;
|
||||
|
||||
namespace SharepointToolbox.Services;
|
||||
|
||||
@@ -13,9 +15,27 @@ namespace SharepointToolbox.Services;
|
||||
/// </summary>
|
||||
public class DuplicatesService : IDuplicatesService
|
||||
{
|
||||
// SharePoint Search REST API caps RowLimit at 500 per request; larger values are silently clamped.
|
||||
private const int BatchSize = 500;
|
||||
|
||||
// SharePoint Search hard ceiling — StartRow > 50,000 returns an error regardless of pagination state.
|
||||
// See https://learn.microsoft.com/sharepoint/dev/general-development/customizing-search-results-in-sharepoint
|
||||
private const int MaxStartRow = 50_000;
|
||||
|
||||
/// <summary>
|
||||
/// Scans a site for duplicate files or folders and groups matches by the
|
||||
/// composite key configured in <paramref name="options"/> (name plus any
|
||||
/// of size / created / modified / subfolder-count / file-count).
|
||||
/// File mode uses the SharePoint Search API — it is fast but capped at
|
||||
/// 50,000 rows (see <see cref="MaxStartRow"/>). Folder mode uses paginated
|
||||
/// CSOM CAML over every document library on the site. Groups with fewer
|
||||
/// than two items are dropped before return.
|
||||
/// </summary>
|
||||
/// <param name="ctx">Authenticated <see cref="ClientContext"/> for the target site.</param>
|
||||
/// <param name="options">Scope (Files/Folders), optional library filter, and match-key toggles.</param>
|
||||
/// <param name="progress">Receives row-count progress during collection.</param>
|
||||
/// <param name="ct">Cancellation token — honoured between paged requests.</param>
|
||||
/// <returns>Duplicate groups ordered by descending size, then name.</returns>
|
||||
public async Task<IReadOnlyList<DuplicateGroup>> ScanDuplicatesAsync(
|
||||
ClientContext ctx,
|
||||
DuplicateScanOptions options,
|
||||
@@ -70,6 +90,8 @@ public class DuplicatesService : IDuplicatesService
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (siteUrl, siteTitle) = await LoadSiteIdentityAsync(ctx, progress, ct);
|
||||
|
||||
// KQL: all documents, optionally scoped to a library
|
||||
var kqlParts = new List<string> { "ContentType:Document" };
|
||||
if (!string.IsNullOrEmpty(options.Library))
|
||||
@@ -102,10 +124,25 @@ public class DuplicatesService : IDuplicatesService
|
||||
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
|
||||
if (table == null || table.RowCount == 0) break;
|
||||
|
||||
foreach (System.Collections.Hashtable row in table.ResultRows)
|
||||
foreach (var rawRow in table.ResultRows)
|
||||
{
|
||||
var dict = row.Cast<System.Collections.DictionaryEntry>()
|
||||
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty);
|
||||
// CSOM has returned ResultRows as either Hashtable or
|
||||
// Dictionary<string,object> across versions — accept both.
|
||||
IDictionary<string, object> dict;
|
||||
if (rawRow is IDictionary<string, object> generic)
|
||||
{
|
||||
dict = generic;
|
||||
}
|
||||
else if (rawRow is System.Collections.IDictionary legacy)
|
||||
{
|
||||
dict = new Dictionary<string, object>();
|
||||
foreach (System.Collections.DictionaryEntry e in legacy)
|
||||
dict[e.Key.ToString()!] = e.Value ?? string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string path = GetStr(dict, "Path");
|
||||
if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -132,7 +169,9 @@ public class DuplicatesService : IDuplicatesService
|
||||
Library = library,
|
||||
SizeBytes = size,
|
||||
Created = created,
|
||||
Modified = modified
|
||||
Modified = modified,
|
||||
SiteUrl = siteUrl,
|
||||
SiteTitle = siteTitle
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,10 +195,16 @@ public class DuplicatesService : IDuplicatesService
|
||||
{
|
||||
// Load all document libraries on the site
|
||||
ctx.Load(ctx.Web,
|
||||
w => w.Title,
|
||||
w => w.Lists.Include(
|
||||
l => l.Title, l => l.Hidden, l => l.BaseType));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
var siteUrl = ctx.Url;
|
||||
var siteTitle = string.IsNullOrWhiteSpace(ctx.Web.Title)
|
||||
? ReportSplitHelper.DeriveSiteLabel(siteUrl)
|
||||
: ctx.Web.Title;
|
||||
|
||||
var libs = ctx.Web.Lists
|
||||
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
||||
.ToList();
|
||||
@@ -172,19 +217,15 @@ public class DuplicatesService : IDuplicatesService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// No WHERE clause — a WHERE on non-indexed fields (FSObjType) throws the
|
||||
// list-view threshold on libraries > 5,000 items even with pagination.
|
||||
// Filter for folders client-side via FileSystemObjectType below.
|
||||
var camlQuery = new CamlQuery
|
||||
{
|
||||
ViewXml = """
|
||||
<View Scope='RecursiveAll'>
|
||||
<Query>
|
||||
<Where>
|
||||
<Eq>
|
||||
<FieldRef Name='FSObjType' />
|
||||
<Value Type='Integer'>1</Value>
|
||||
</Eq>
|
||||
</Where>
|
||||
</Query>
|
||||
<RowLimit>2000</RowLimit>
|
||||
<Query></Query>
|
||||
<RowLimit Paged='TRUE'>5000</RowLimit>
|
||||
</View>
|
||||
"""
|
||||
};
|
||||
@@ -200,6 +241,8 @@ public class DuplicatesService : IDuplicatesService
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (item.FileSystemObjectType != FileSystemObjectType.Folder) continue;
|
||||
|
||||
var fv = item.FieldValues;
|
||||
string name = fv["FileLeafRef"]?.ToString() ?? string.Empty;
|
||||
string fileRef = fv["FileRef"]?.ToString() ?? string.Empty;
|
||||
@@ -217,7 +260,9 @@ public class DuplicatesService : IDuplicatesService
|
||||
FolderCount = subCount,
|
||||
FileCount = fileCount,
|
||||
Created = created,
|
||||
Modified = modified
|
||||
Modified = modified,
|
||||
SiteUrl = siteUrl,
|
||||
SiteTitle = siteTitle
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -246,6 +291,37 @@ public class DuplicatesService : IDuplicatesService
|
||||
private static DateTime? ParseDate(string s) =>
|
||||
DateTime.TryParse(s, out var dt) ? dt : (DateTime?)null;
|
||||
|
||||
private static async Task<(string Url, string Title)> LoadSiteIdentityAsync(
|
||||
ClientContext ctx, IProgress<OperationProgress> progress, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
ctx.Load(ctx.Web, w => w.Title);
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Best-effort — fall back to URL-derived label
|
||||
Debug.WriteLine($"[DuplicatesService] LoadSiteIdentityAsync: failed to load Web.Title: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
var url = ctx.Url ?? string.Empty;
|
||||
string title;
|
||||
try { title = ctx.Web.Title; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[DuplicatesService] LoadSiteIdentityAsync: Web.Title getter threw: {ex.GetType().Name}: {ex.Message}");
|
||||
title = string.Empty;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
title = ReportSplitHelper.DeriveSiteLabel(url);
|
||||
return (url, title);
|
||||
}
|
||||
|
||||
private static string ExtractLibraryFromPath(string path, string siteUrl)
|
||||
{
|
||||
// Extract first path segment after the site URL as library name
|
||||
|
||||
Reference in New Issue
Block a user