diff --git a/SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs b/SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs new file mode 100644 index 0000000..7b353e5 --- /dev/null +++ b/SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs @@ -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; + + /// + /// Executes a SharePoint query with automatic retry on throttle (429/503). + /// Surfaces retry events via progress for user visibility ("Throttled — retrying in 30s…"). + /// + public static async Task ExecuteQueryRetryAsync( + ClientContext ctx, + IProgress? 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); + } +} diff --git a/SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs b/SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs new file mode 100644 index 0000000..151634e --- /dev/null +++ b/SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs @@ -0,0 +1,56 @@ +using System.Runtime.CompilerServices; +using Microsoft.SharePoint.Client; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Core.Helpers; + +public static class SharePointPaginationHelper +{ + /// + /// 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. + /// + public static async IAsyncEnumerable 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 $"{rowLimit}"; + + // Simple replacement approach — adequate for Phase 1 + if (existingXml.Contains("", StringComparison.OrdinalIgnoreCase)) + { + return System.Text.RegularExpressions.Regex.Replace( + existingXml, @"]*>\d+", + $"{rowLimit}", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + return existingXml.Replace("", $"{rowLimit}", + StringComparison.OrdinalIgnoreCase); + } +} diff --git a/SharepointToolbox/Infrastructure/Logging/LogPanelSink.cs b/SharepointToolbox/Infrastructure/Logging/LogPanelSink.cs new file mode 100644 index 0000000..a5e7730 --- /dev/null +++ b/SharepointToolbox/Infrastructure/Logging/LogPanelSink.cs @@ -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; + +/// +/// 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. +/// +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 + }; +}