feat(01-02): add SharePointPaginationHelper, ExecuteQueryRetryHelper, LogPanelSink
- SharePointPaginationHelper: async iterator with ListItemCollectionPosition loop (bypasses 5k limit); RowLimit=2000; [EnumeratorCancellation] for correct WithCancellation support - ExecuteQueryRetryHelper: exponential backoff on 429/503/throttle; surfaces retry events via IProgress<OperationProgress>; max 5 retries - LogPanelSink: custom Serilog ILogEventSink writing color-coded entries to RichTextBox via Dispatcher.InvokeAsync for thread safety
This commit is contained in:
45
SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs
Normal file
45
SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Core.Helpers;
|
||||||
|
|
||||||
|
public static class ExecuteQueryRetryHelper
|
||||||
|
{
|
||||||
|
private const int MaxRetries = 5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes a SharePoint query with automatic retry on throttle (429/503).
|
||||||
|
/// Surfaces retry events via progress for user visibility ("Throttled — retrying in 30s…").
|
||||||
|
/// </summary>
|
||||||
|
public static async Task ExecuteQueryRetryAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
IProgress<OperationProgress>? progress = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
int attempt = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ctx.ExecuteQueryAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (IsThrottleException(ex) && attempt < MaxRetries)
|
||||||
|
{
|
||||||
|
attempt++;
|
||||||
|
int delaySeconds = (int)Math.Pow(2, attempt) * 5; // 10, 20, 40, 80, 160s
|
||||||
|
progress?.Report(OperationProgress.Indeterminate(
|
||||||
|
$"Throttled by SharePoint — retrying in {delaySeconds}s (attempt {attempt}/{MaxRetries})…"));
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsThrottleException(Exception ex)
|
||||||
|
{
|
||||||
|
var msg = ex.Message;
|
||||||
|
return msg.Contains("429") || msg.Contains("503") ||
|
||||||
|
msg.Contains("throttl", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs
Normal file
56
SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Core.Helpers;
|
||||||
|
|
||||||
|
public static class SharePointPaginationHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Enumerates all items in a SharePoint list, bypassing the 5,000-item threshold.
|
||||||
|
/// Uses CamlQuery with RowLimit=2000 and ListItemCollectionPosition for pagination.
|
||||||
|
/// Never call ExecuteQuery directly on a list — always use this helper.
|
||||||
|
/// </summary>
|
||||||
|
public static async IAsyncEnumerable<ListItem> GetAllItemsAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
List list,
|
||||||
|
CamlQuery? baseQuery = null,
|
||||||
|
[EnumeratorCancellation] CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var query = baseQuery ?? CamlQuery.CreateAllItemsQuery();
|
||||||
|
query.ViewXml = BuildPagedViewXml(query.ViewXml, rowLimit: 2000);
|
||||||
|
query.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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>";
|
||||||
|
|
||||||
|
// Simple replacement approach — adequate for Phase 1
|
||||||
|
if (existingXml.Contains("<RowLimit>", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
|
||||||
|
$"<RowLimit>{rowLimit}</RowLimit>",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
}
|
||||||
|
return existingXml.Replace("</View>", $"<RowLimit>{rowLimit}</RowLimit></View>",
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
SharepointToolbox/Infrastructure/Logging/LogPanelSink.cs
Normal file
49
SharepointToolbox/Infrastructure/Logging/LogPanelSink.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Documents;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Infrastructure.Logging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom Serilog sink that writes timestamped, color-coded entries to a WPF RichTextBox.
|
||||||
|
/// Format: HH:mm:ss [LEVEL] Message — green=info/success, orange=warning, red=error.
|
||||||
|
/// All writes dispatch to the UI thread via Application.Current.Dispatcher.
|
||||||
|
/// </summary>
|
||||||
|
public class LogPanelSink : ILogEventSink
|
||||||
|
{
|
||||||
|
private readonly RichTextBox _richTextBox;
|
||||||
|
|
||||||
|
public LogPanelSink(RichTextBox richTextBox)
|
||||||
|
{
|
||||||
|
_richTextBox = richTextBox;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Emit(LogEvent logEvent)
|
||||||
|
{
|
||||||
|
var message = logEvent.RenderMessage();
|
||||||
|
var timestamp = logEvent.Timestamp.ToString("HH:mm:ss");
|
||||||
|
var level = logEvent.Level.ToString().ToUpperInvariant()[..4]; // INFO, WARN, ERRO, FATL
|
||||||
|
var text = $"{timestamp} [{level}] {message}";
|
||||||
|
var color = GetColor(logEvent.Level);
|
||||||
|
|
||||||
|
Application.Current?.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
var para = new Paragraph(new Run(text) { Foreground = new SolidColorBrush(color) })
|
||||||
|
{
|
||||||
|
Margin = new Thickness(0)
|
||||||
|
};
|
||||||
|
_richTextBox.Document.Blocks.Add(para);
|
||||||
|
_richTextBox.ScrollToEnd();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color GetColor(LogEventLevel level) => level switch
|
||||||
|
{
|
||||||
|
LogEventLevel.Warning => Colors.Orange,
|
||||||
|
LogEventLevel.Error or LogEventLevel.Fatal => Colors.Red,
|
||||||
|
_ => Colors.LimeGreen
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user