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
@@ -6,9 +6,12 @@ namespace SharepointToolbox.Core.Helpers;
public static class SharePointPaginationHelper
{
// Max page size SharePoint honors with Paged='TRUE' (threshold bypass).
private const int DefaultRowLimit = 5000;
/// <summary>
/// Enumerates all items in a SharePoint list, bypassing the 5,000-item threshold.
/// Uses CamlQuery with RowLimit=2000 and ListItemCollectionPosition for pagination.
/// Uses CamlQuery with Paged='TRUE' RowLimit and ListItemCollectionPosition for pagination.
/// Never call ExecuteQuery directly on a list — always use this helper.
/// </summary>
public static async IAsyncEnumerable<ListItem> GetAllItemsAsync(
@@ -18,7 +21,7 @@ public static class SharePointPaginationHelper
[EnumeratorCancellation] CancellationToken ct = default)
{
var query = baseQuery ?? CamlQuery.CreateAllItemsQuery();
query.ViewXml = BuildPagedViewXml(query.ViewXml, rowLimit: 2000);
query.ViewXml = BuildPagedViewXml(query.ViewXml, DefaultRowLimit);
query.ListItemCollectionPosition = null;
do
@@ -36,21 +39,75 @@ public static class SharePointPaginationHelper
while (query.ListItemCollectionPosition != null);
}
/// <summary>
/// Enumerates items within a specific folder (direct children by default, or
/// recursive when <paramref name="recursive"/> is true). Uses paginated CAML
/// with no WHERE clause so it works on libraries above the 5,000-item threshold.
/// Callers filter by FSObjType client-side via the returned ListItem fields.
/// </summary>
public static async IAsyncEnumerable<ListItem> GetItemsInFolderAsync(
ClientContext ctx,
List list,
string folderServerRelativeUrl,
bool recursive,
string[]? viewFields = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var fields = viewFields ?? new[]
{
"FSObjType", "FileRef", "FileLeafRef", "FileDirRef", "File_x0020_Size"
};
var viewFieldsXml = string.Join(string.Empty,
fields.Select(f => $"<FieldRef Name='{f}' />"));
var scope = recursive ? " Scope='RecursiveAll'" : string.Empty;
var viewXml =
$"<View{scope}>" +
"<Query></Query>" +
$"<ViewFields>{viewFieldsXml}</ViewFields>" +
$"<RowLimit Paged='TRUE'>{DefaultRowLimit}</RowLimit>" +
"</View>";
var query = new CamlQuery
{
ViewXml = viewXml,
FolderServerRelativeUrl = folderServerRelativeUrl,
ListItemCollectionPosition = null
};
do
{
ct.ThrowIfCancellationRequested();
var items = list.GetItems(query);
ctx.Load(items);
await ctx.ExecuteQueryAsync();
foreach (var item in items)
yield return item;
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
}
while (query.ListItemCollectionPosition != null);
}
internal static string BuildPagedViewXml(string? existingXml, int rowLimit)
{
// Inject or replace RowLimit in existing CAML, or create minimal view
if (string.IsNullOrWhiteSpace(existingXml))
return $"<View><RowLimit>{rowLimit}</RowLimit></View>";
return $"<View><RowLimit Paged='TRUE'>{rowLimit}</RowLimit></View>";
// Simple replacement approach — adequate for Phase 1
if (existingXml.Contains("<RowLimit>", StringComparison.OrdinalIgnoreCase))
// Replace any existing <RowLimit ...>n</RowLimit> with paged form.
if (System.Text.RegularExpressions.Regex.IsMatch(
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
return System.Text.RegularExpressions.Regex.Replace(
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
$"<RowLimit>{rowLimit}</RowLimit>",
$"<RowLimit Paged='TRUE'>{rowLimit}</RowLimit>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
}
return existingXml.Replace("</View>", $"<RowLimit>{rowLimit}</RowLimit></View>",
return existingXml.Replace("</View>",
$"<RowLimit Paged='TRUE'>{rowLimit}</RowLimit></View>",
StringComparison.OrdinalIgnoreCase);
}
}