All checks were successful
Release zip package / release (push) Successful in 10s
Archive 5 phases (36 plans) to milestones/v1.0-phases/. Archive roadmap, requirements, and audit to milestones/. Evolve PROJECT.md with shipped state and validated requirements. Collapse ROADMAP.md to one-line milestone summary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
342 lines
13 KiB
Markdown
342 lines
13 KiB
Markdown
---
|
|
phase: 01-foundation
|
|
plan: 02
|
|
type: execute
|
|
wave: 2
|
|
depends_on:
|
|
- 01-01
|
|
files_modified:
|
|
- SharepointToolbox/Core/Models/TenantProfile.cs
|
|
- SharepointToolbox/Core/Models/OperationProgress.cs
|
|
- SharepointToolbox/Core/Messages/TenantSwitchedMessage.cs
|
|
- SharepointToolbox/Core/Messages/LanguageChangedMessage.cs
|
|
- SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs
|
|
- SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs
|
|
- SharepointToolbox/Infrastructure/Logging/LogPanelSink.cs
|
|
autonomous: true
|
|
requirements:
|
|
- FOUND-05
|
|
- FOUND-06
|
|
- FOUND-07
|
|
- FOUND-08
|
|
must_haves:
|
|
truths:
|
|
- "OperationProgress record is usable by all feature services for IProgress<T> reporting"
|
|
- "TenantSwitchedMessage and LanguageChangedMessage are broadcast-ready via WeakReferenceMessenger"
|
|
- "SharePointPaginationHelper iterates past 5,000 items using ListItemCollectionPosition"
|
|
- "ExecuteQueryRetryHelper surfaces retry events as IProgress messages"
|
|
- "LogPanelSink writes to a RichTextBox-targeted dispatcher-safe callback"
|
|
artifacts:
|
|
- path: "SharepointToolbox/Core/Models/OperationProgress.cs"
|
|
provides: "Shared progress record used by all feature services"
|
|
contains: "record OperationProgress"
|
|
- path: "SharepointToolbox/Core/Models/TenantProfile.cs"
|
|
provides: "Profile model matching JSON schema"
|
|
contains: "TenantUrl"
|
|
- path: "SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs"
|
|
provides: "CSOM list pagination wrapping CamlQuery + ListItemCollectionPosition"
|
|
contains: "ListItemCollectionPosition"
|
|
- path: "SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs"
|
|
provides: "Retry wrapper for CSOM calls with throttle detection"
|
|
contains: "ExecuteQueryRetryAsync"
|
|
- path: "SharepointToolbox/Infrastructure/Logging/LogPanelSink.cs"
|
|
provides: "Custom Serilog sink that writes to UI log panel"
|
|
contains: "ILogEventSink"
|
|
key_links:
|
|
- from: "SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs"
|
|
to: "Microsoft.SharePoint.Client.ListItemCollectionPosition"
|
|
via: "PnP.Framework CSOM"
|
|
pattern: "ListItemCollectionPosition"
|
|
- from: "SharepointToolbox/Infrastructure/Logging/LogPanelSink.cs"
|
|
to: "Application.Current.Dispatcher"
|
|
via: "InvokeAsync for thread safety"
|
|
pattern: "Dispatcher.InvokeAsync"
|
|
---
|
|
|
|
<objective>
|
|
Build the Core layer — models, messages, and infrastructure helpers — that every subsequent plan depends on. These are the contracts: no business logic, just types and patterns.
|
|
|
|
Purpose: All feature phases import OperationProgress, TenantProfile, the pagination helper, and the retry helper. Getting these right here means no rework in Phases 2-4.
|
|
Output: Core/Models, Core/Messages, Core/Helpers, Infrastructure/Logging directories with 7 files.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
|
|
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/phases/01-foundation/01-CONTEXT.md
|
|
@.planning/phases/01-foundation/01-RESEARCH.md
|
|
@.planning/phases/01-foundation/01-01-SUMMARY.md
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Core models and WeakReferenceMessenger messages</name>
|
|
<files>
|
|
SharepointToolbox/Core/Models/TenantProfile.cs,
|
|
SharepointToolbox/Core/Models/OperationProgress.cs,
|
|
SharepointToolbox/Core/Messages/TenantSwitchedMessage.cs,
|
|
SharepointToolbox/Core/Messages/LanguageChangedMessage.cs
|
|
</files>
|
|
<action>
|
|
Create directories: `Core/Models/`, `Core/Messages/`
|
|
|
|
**TenantProfile.cs**
|
|
```csharp
|
|
namespace SharepointToolbox.Core.Models;
|
|
|
|
public class TenantProfile
|
|
{
|
|
public string Name { get; set; } = string.Empty;
|
|
public string TenantUrl { get; set; } = string.Empty;
|
|
public string ClientId { get; set; } = string.Empty;
|
|
}
|
|
```
|
|
Note: Plain class (not record) — mutable for JSON deserialization with System.Text.Json. Field names `Name`, `TenantUrl`, `ClientId` must match existing JSON schema exactly (case-insensitive by default in STJ but preserve casing for compatibility).
|
|
|
|
**OperationProgress.cs**
|
|
```csharp
|
|
namespace SharepointToolbox.Core.Models;
|
|
|
|
public record OperationProgress(int Current, int Total, string Message)
|
|
{
|
|
public static OperationProgress Indeterminate(string message) =>
|
|
new(0, 0, message);
|
|
}
|
|
```
|
|
|
|
**TenantSwitchedMessage.cs**
|
|
```csharp
|
|
using CommunityToolkit.Mvvm.Messaging.Messages;
|
|
using SharepointToolbox.Core.Models;
|
|
|
|
namespace SharepointToolbox.Core.Messages;
|
|
|
|
public sealed class TenantSwitchedMessage : ValueChangedMessage<TenantProfile>
|
|
{
|
|
public TenantSwitchedMessage(TenantProfile profile) : base(profile) { }
|
|
}
|
|
```
|
|
|
|
**LanguageChangedMessage.cs**
|
|
```csharp
|
|
using CommunityToolkit.Mvvm.Messaging.Messages;
|
|
|
|
namespace SharepointToolbox.Core.Messages;
|
|
|
|
public sealed class LanguageChangedMessage : ValueChangedMessage<string>
|
|
{
|
|
public LanguageChangedMessage(string cultureCode) : base(cultureCode) { }
|
|
}
|
|
```
|
|
|
|
Run `dotnet build SharepointToolbox/SharepointToolbox.csproj` to verify no errors.
|
|
</action>
|
|
<verify>
|
|
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj 2>&1 | tail -5</automated>
|
|
</verify>
|
|
<done>Build succeeds. Four files created. TenantProfile fields match JSON schema. OperationProgress is a record with Indeterminate factory.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: SharePointPaginationHelper, ExecuteQueryRetryHelper, LogPanelSink</name>
|
|
<files>
|
|
SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs,
|
|
SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs,
|
|
SharepointToolbox/Infrastructure/Logging/LogPanelSink.cs
|
|
</files>
|
|
<action>
|
|
Create directories: `Core/Helpers/`, `Infrastructure/Logging/`
|
|
|
|
**SharePointPaginationHelper.cs**
|
|
```csharp
|
|
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,
|
|
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);
|
|
}
|
|
}
|
|
```
|
|
|
|
**ExecuteQueryRetryHelper.cs**
|
|
```csharp
|
|
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);
|
|
}
|
|
}
|
|
```
|
|
|
|
**LogPanelSink.cs**
|
|
```csharp
|
|
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
|
|
};
|
|
}
|
|
```
|
|
|
|
Run `dotnet build SharepointToolbox/SharepointToolbox.csproj` to verify no errors.
|
|
</action>
|
|
<verify>
|
|
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj 2>&1 | tail -5</automated>
|
|
</verify>
|
|
<done>Build succeeds. SharePointPaginationHelper uses ListItemCollectionPosition. ExecuteQueryRetryHelper detects throttle exceptions. LogPanelSink dispatches to UI thread via Dispatcher.InvokeAsync.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `dotnet build SharepointToolbox.sln` passes with 0 errors
|
|
- `SharepointToolbox/Core/Models/TenantProfile.cs` contains `TenantUrl` (not `TenantURL` or `Url`) to match JSON schema
|
|
- `SharePointPaginationHelper.cs` contains `ListItemCollectionPosition` and loop condition checking for null
|
|
- `ExecuteQueryRetryHelper.cs` contains exponential backoff and progress reporting
|
|
- `LogPanelSink.cs` contains `Dispatcher.InvokeAsync`
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
All 7 Core/Infrastructure files created and compiling. Models match JSON schema field names. Pagination helper correctly loops until ListItemCollectionPosition is null.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/01-foundation/01-02-SUMMARY.md`
|
|
</output>
|