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
+ };
+}