docs(01-foundation): create phase plan (8 plans, 6 waves)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-02 11:38:35 +02:00
parent f303a60018
commit ff5ac94ae2
9 changed files with 2177 additions and 2 deletions

View File

@@ -38,7 +38,17 @@ Decimal phases appear between their surrounding integers in numeric order.
3. User can see real-time progress on any long-running operation and cancel it mid-execution with a button — the operation stops cleanly with no silent continuation 3. User can see real-time progress on any long-running operation and cancel it mid-execution with a button — the operation stops cleanly with no silent continuation
4. When any operation fails, the user sees an actionable error message in the UI — no operation fails silently or swallows an exception 4. When any operation fails, the user sees an actionable error message in the UI — no operation fails silently or swallows an exception
5. UI language switches between English and French dynamically without restarting the application 5. UI language switches between English and French dynamically without restarting the application
**Plans**: TBD **Plans**: 8 plans
Plans:
- [ ] 01-01-PLAN.md — Solution scaffold: WPF project + xUnit test project with Generic Host entry point
- [ ] 01-02-PLAN.md — Core layer: models, messages, pagination helper, retry helper, LogPanelSink
- [ ] 01-03-PLAN.md — Persistence layer: ProfileRepository + SettingsRepository + services + unit tests
- [ ] 01-04-PLAN.md — Auth layer: MsalClientFactory + SessionManager + unit tests
- [ ] 01-05-PLAN.md — Localization + Serilog: TranslationSource, EN/FR resx, integration tests
- [ ] 01-06-PLAN.md — ViewModels + WPF shell: FeatureViewModelBase, MainWindow XAML, global exception handlers
- [ ] 01-07-PLAN.md — UI dialogs: ProfileManagementDialog + SettingsView wired into shell
- [ ] 01-08-PLAN.md — Checkpoint: full test suite + visual verification of running application
### Phase 2: Permissions ### Phase 2: Permissions
**Goal**: Users can scan SharePoint permissions on one or many sites and export the results as both a raw CSV and a sortable, filterable HTML report — with no silent failures on large libraries and full control over scan scope. **Goal**: Users can scan SharePoint permissions on one or many sites and export the results as both a raw CSV and a sortable, filterable HTML report — with no silent failures on large libraries and full control over scan scope.
@@ -94,7 +104,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5
| Phase | Plans Complete | Status | Completed | | Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 1. Foundation | 0/? | Not started | - | | 1. Foundation | 0/8 | Planning done | - |
| 2. Permissions | 0/? | Not started | - | | 2. Permissions | 0/? | Not started | - |
| 3. Storage and File Operations | 0/? | Not started | - | | 3. Storage and File Operations | 0/? | Not started | - |
| 4. Bulk Operations and Provisioning | 0/? | Not started | - | | 4. Bulk Operations and Provisioning | 0/? | Not started | - |

View File

@@ -0,0 +1,226 @@
---
phase: 01-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- SharepointToolbox/SharepointToolbox.csproj
- SharepointToolbox/App.xaml
- SharepointToolbox/App.xaml.cs
- SharepointToolbox.Tests/SharepointToolbox.Tests.csproj
- SharepointToolbox.Tests/Services/ProfileServiceTests.cs
- SharepointToolbox.Tests/Services/SettingsServiceTests.cs
- SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs
- SharepointToolbox.Tests/Auth/SessionManagerTests.cs
- SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs
- SharepointToolbox.Tests/Localization/TranslationSourceTests.cs
- SharepointToolbox.Tests/Integration/LoggingIntegrationTests.cs
- SharepointToolbox.sln
autonomous: true
requirements:
- FOUND-01
must_haves:
truths:
- "dotnet build produces zero errors"
- "dotnet test produces zero test failures (all tests pending/skipped)"
- "Solution contains two projects: SharepointToolbox (WPF) and SharepointToolbox.Tests (xUnit)"
- "App.xaml has no StartupUri — Generic Host entry point is wired"
artifacts:
- path: "SharepointToolbox/SharepointToolbox.csproj"
provides: "WPF .NET 10 project with all NuGet packages"
contains: "PublishTrimmed>false"
- path: "SharepointToolbox/App.xaml.cs"
provides: "Generic Host entry point with [STAThread]"
contains: "Host.CreateDefaultBuilder"
- path: "SharepointToolbox.Tests/SharepointToolbox.Tests.csproj"
provides: "xUnit test project"
contains: "xunit"
- path: "SharepointToolbox.sln"
provides: "Solution file with both projects"
key_links:
- from: "SharepointToolbox/App.xaml.cs"
to: "SharepointToolbox/App.xaml"
via: "x:Class reference + StartupUri removed"
pattern: "StartupUri"
- from: "SharepointToolbox/SharepointToolbox.csproj"
to: "App.xaml"
via: "Page include replacing ApplicationDefinition"
pattern: "ApplicationDefinition"
---
<objective>
Create the solution scaffold: WPF .NET 10 project with all NuGet packages wired, Generic Host entry point, and xUnit test project with stub test files that compile but have no passing tests yet.
Purpose: Every subsequent plan builds on a compiling, test-wired foundation. Getting the Generic Host + WPF STA threading right here prevents the most common startup crash.
Output: SharepointToolbox.sln with two projects, zero build errors, zero test failures on first run.
</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/STATE.md
@.planning/phases/01-foundation/01-CONTEXT.md
@.planning/phases/01-foundation/01-RESEARCH.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Create solution and WPF project with all NuGet packages</name>
<files>
SharepointToolbox.sln,
SharepointToolbox/SharepointToolbox.csproj,
SharepointToolbox/App.xaml,
SharepointToolbox/App.xaml.cs,
SharepointToolbox/MainWindow.xaml,
SharepointToolbox/MainWindow.xaml.cs
</files>
<action>
Run from the repo root:
```
dotnet new sln -n SharepointToolbox
dotnet new wpf -n SharepointToolbox -f net10.0-windows
dotnet sln add SharepointToolbox/SharepointToolbox.csproj
```
Edit SharepointToolbox/SharepointToolbox.csproj:
- Set `<TargetFramework>net10.0-windows</TargetFramework>`
- Add `<Nullable>enable</Nullable>`, `<ImplicitUsings>enable</ImplicitUsings>`
- Add `<PublishTrimmed>false</PublishTrimmed>` (critical — PnP.Framework + MSAL use reflection)
- Add `<StartupObject>SharepointToolbox.App</StartupObject>`
- Add NuGet packages:
- `CommunityToolkit.Mvvm` version 8.4.2
- `Microsoft.Extensions.Hosting` version 10.x (latest 10.x)
- `Microsoft.Identity.Client` version 4.83.1
- `Microsoft.Identity.Client.Extensions.Msal` version 4.83.3
- `Microsoft.Identity.Client.Broker` version 4.82.1
- `PnP.Framework` version 1.18.0
- `Serilog` version 4.3.1
- `Serilog.Sinks.File` (latest)
- `Serilog.Extensions.Hosting` (latest)
- Change `<ApplicationDefinition Remove="App.xaml" />` and `<Page Include="App.xaml" />` to demote App.xaml from ApplicationDefinition
Edit App.xaml: Remove `StartupUri="MainWindow.xaml"`. Keep `x:Class="SharepointToolbox.App"`.
Edit App.xaml.cs: Replace default App class with Generic Host entry point pattern:
```csharp
public partial class App : Application
{
[STAThread]
public static void Main(string[] args)
{
using IHost host = Host.CreateDefaultBuilder(args)
.UseSerilog((ctx, cfg) => cfg
.WriteTo.File(
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SharepointToolbox", "logs", "app-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30))
.ConfigureServices(RegisterServices)
.Build();
host.Start();
App app = new();
app.InitializeComponent();
app.MainWindow = host.Services.GetRequiredService<MainWindow>();
app.MainWindow.Visibility = Visibility.Visible;
app.Run();
}
private static void RegisterServices(HostBuilderContext ctx, IServiceCollection services)
{
// Placeholder — services registered in subsequent plans
services.AddSingleton<MainWindow>();
}
}
```
Leave MainWindow.xaml and MainWindow.xaml.cs as the default WPF template output — they will be replaced in plan 01-06.
Run `dotnet build SharepointToolbox/SharepointToolbox.csproj` and fix any errors before moving to Task 2.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5</automated>
</verify>
<done>Build output shows "Build succeeded" with 0 errors. App.xaml has no StartupUri. csproj contains PublishTrimmed=false and StartupObject.</done>
</task>
<task type="auto">
<name>Task 2: Create xUnit test project with stub test files</name>
<files>
SharepointToolbox.Tests/SharepointToolbox.Tests.csproj,
SharepointToolbox.Tests/Services/ProfileServiceTests.cs,
SharepointToolbox.Tests/Services/SettingsServiceTests.cs,
SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs,
SharepointToolbox.Tests/Auth/SessionManagerTests.cs,
SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs,
SharepointToolbox.Tests/Localization/TranslationSourceTests.cs,
SharepointToolbox.Tests/Integration/LoggingIntegrationTests.cs
</files>
<action>
Run from the repo root:
```
dotnet new xunit -n SharepointToolbox.Tests -f net10.0
dotnet sln add SharepointToolbox.Tests/SharepointToolbox.Tests.csproj
dotnet add SharepointToolbox.Tests/SharepointToolbox.Tests.csproj reference SharepointToolbox/SharepointToolbox.csproj
```
Edit SharepointToolbox.Tests/SharepointToolbox.Tests.csproj:
- Add `Moq` (latest) NuGet package
- Add `Microsoft.NET.Test.Sdk` (already included in xunit template)
- Add `<Nullable>enable</Nullable>`, `<ImplicitUsings>enable</ImplicitUsings>`
Create stub test files — each file compiles but has a single `[Fact(Skip = "Not implemented yet")]` test so the suite passes (no failures, just skips):
**SharepointToolbox.Tests/Services/ProfileServiceTests.cs**
```csharp
namespace SharepointToolbox.Tests.Services;
public class ProfileServiceTests
{
[Fact(Skip = "Wave 0 stub — implemented in plan 01-03")]
public void SaveAndLoad_RoundTrips_Profiles() { }
}
```
Create identical stub pattern for:
- `SettingsServiceTests.cs` — class `SettingsServiceTests`, skip reason "plan 01-03"
- `MsalClientFactoryTests.cs` — class `MsalClientFactoryTests`, skip reason "plan 01-04"
- `SessionManagerTests.cs` — class `SessionManagerTests`, skip reason "plan 01-04"
- `FeatureViewModelBaseTests.cs` — class `FeatureViewModelBaseTests`, skip reason "plan 01-06"
- `TranslationSourceTests.cs` — class `TranslationSourceTests`, skip reason "plan 01-05"
- `LoggingIntegrationTests.cs` — class `LoggingIntegrationTests`, skip reason "plan 01-05"
Run `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --no-build` after building to confirm all tests are skipped (0 failed).
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj 2>&1 | tail -10</automated>
</verify>
<done>dotnet test shows 0 failed, 7 skipped (or similar). All stub test files exist in correct subdirectories.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox.sln` succeeds with 0 errors
- `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` shows 0 failures
- App.xaml contains no StartupUri attribute
- SharepointToolbox.csproj contains `<PublishTrimmed>false</PublishTrimmed>`
- SharepointToolbox.csproj contains `<StartupObject>SharepointToolbox.App</StartupObject>`
- App.xaml.cs Main method is decorated with `[STAThread]`
</verification>
<success_criteria>
Solution compiles cleanly. Both projects in the solution. Test runner executes without failures. Generic Host wiring is correct (most critical risk for this plan — wrong STA threading causes runtime crash).
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation/01-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,341 @@
---
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>

View File

@@ -0,0 +1,254 @@
---
phase: 01-foundation
plan: 03
type: execute
wave: 2
depends_on:
- 01-01
- 01-02
files_modified:
- SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs
- SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs
- SharepointToolbox/Services/ProfileService.cs
- SharepointToolbox/Services/SettingsService.cs
- SharepointToolbox.Tests/Services/ProfileServiceTests.cs
- SharepointToolbox.Tests/Services/SettingsServiceTests.cs
autonomous: true
requirements:
- FOUND-02
- FOUND-10
- FOUND-12
must_haves:
truths:
- "ProfileService reads Sharepoint_Export_profiles.json without migration — field names are the contract"
- "SettingsService reads Sharepoint_Settings.json preserving dataFolder and lang fields"
- "Write operations use write-then-replace (file.tmp → validate → File.Move) with SemaphoreSlim(1)"
- "ProfileService unit tests: SaveAndLoad round-trips, corrupt file recovery, concurrent write safety"
- "SettingsService unit tests: SaveAndLoad round-trips, default settings when file missing"
artifacts:
- path: "SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs"
provides: "File I/O for profiles JSON with write-then-replace"
contains: "SemaphoreSlim"
- path: "SharepointToolbox/Services/ProfileService.cs"
provides: "CRUD operations on TenantProfile collection"
exports: ["AddProfile", "RenameProfile", "DeleteProfile", "GetProfiles"]
- path: "SharepointToolbox/Services/SettingsService.cs"
provides: "Read/write for app settings including data folder and language"
exports: ["GetSettings", "SaveSettings"]
- path: "SharepointToolbox.Tests/Services/ProfileServiceTests.cs"
provides: "Unit tests covering FOUND-02 and FOUND-10"
key_links:
- from: "SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs"
to: "Sharepoint_Export_profiles.json"
via: "System.Text.Json deserialization of { profiles: [...] } wrapper"
pattern: "profiles"
- from: "SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs"
to: "Sharepoint_Settings.json"
via: "System.Text.Json deserialization of { dataFolder, lang }"
pattern: "dataFolder"
---
<objective>
Build the persistence layer: ProfileRepository and SettingsRepository (Infrastructure) plus ProfileService and SettingsService (Services layer). Implement write-then-replace safety. Write unit tests that validate the round-trip and edge cases.
Purpose: Profiles and settings are the first user-visible data. Corrupt files or wrong field names would break existing users' data on migration. Unit tests lock in the JSON schema contract.
Output: 4 production files + 2 test files with passing unit tests.
</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/phases/01-foundation/01-CONTEXT.md
@.planning/phases/01-foundation/01-RESEARCH.md
@.planning/phases/01-foundation/01-02-SUMMARY.md
<interfaces>
<!-- From Core/Models/TenantProfile.cs (plan 01-02) -->
```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;
}
```
<!-- JSON schema contracts (live user data — field names are frozen) -->
// Sharepoint_Export_profiles.json
{ "profiles": [{ "name": "...", "tenantUrl": "...", "clientId": "..." }] }
// Sharepoint_Settings.json
{ "dataFolder": "...", "lang": "en" }
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: ProfileRepository and ProfileService with write-then-replace</name>
<files>
SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs,
SharepointToolbox/Services/ProfileService.cs,
SharepointToolbox.Tests/Services/ProfileServiceTests.cs
</files>
<behavior>
- Test: SaveAsync then LoadAsync round-trips a list of TenantProfiles with correct field values
- Test: LoadAsync on missing file returns empty list (no exception)
- Test: LoadAsync on corrupt JSON throws InvalidDataException (not silently returns empty)
- Test: Concurrent SaveAsync calls don't corrupt the file (SemaphoreSlim ensures ordering)
- Test: ProfileService.AddProfile assigns the new profile and persists immediately
- Test: ProfileService.RenameProfile changes Name, persists, throws if profile not found
- Test: ProfileService.DeleteProfile removes by Name, throws if not found
- Test: Saved JSON wraps profiles in { "profiles": [...] } root object (schema compatibility)
</behavior>
<action>
Create `Infrastructure/Persistence/` and `Services/` directories.
**ProfileRepository.cs** — handles raw file I/O:
```csharp
namespace SharepointToolbox.Infrastructure.Persistence;
public class ProfileRepository
{
private readonly string _filePath;
private readonly SemaphoreSlim _writeLock = new(1, 1);
public ProfileRepository(string filePath)
{
_filePath = filePath;
}
public async Task<IReadOnlyList<TenantProfile>> LoadAsync()
{
if (!File.Exists(_filePath))
return Array.Empty<TenantProfile>();
var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8);
var root = JsonSerializer.Deserialize<ProfilesRoot>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return root?.Profiles ?? Array.Empty<TenantProfile>();
}
public async Task SaveAsync(IReadOnlyList<TenantProfile> profiles)
{
await _writeLock.WaitAsync();
try
{
var root = new ProfilesRoot { Profiles = profiles.ToList() };
var json = JsonSerializer.Serialize(root,
new JsonSerializerOptions { WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var tmpPath = _filePath + ".tmp";
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
// Validate round-trip before replacing
JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath)).Dispose();
File.Move(tmpPath, _filePath, overwrite: true);
}
finally { _writeLock.Release(); }
}
private sealed class ProfilesRoot
{
public List<TenantProfile> Profiles { get; set; } = new();
}
}
```
Note: Use `PropertyNamingPolicy = JsonNamingPolicy.CamelCase` to serialize `Name`→`name`, `TenantUrl`→`tenantUrl`, `ClientId`→`clientId` matching the existing JSON schema.
**ProfileService.cs** — CRUD on top of repository:
- Constructor takes `ProfileRepository` (inject via DI later; for now accept in constructor)
- `Task<IReadOnlyList<TenantProfile>> GetProfilesAsync()`
- `Task AddProfileAsync(TenantProfile profile)` — validates Name not empty, TenantUrl valid URL, ClientId not empty; throws `ArgumentException` for invalid inputs
- `Task RenameProfileAsync(string existingName, string newName)` — throws `KeyNotFoundException` if not found
- `Task DeleteProfileAsync(string name)` — throws `KeyNotFoundException` if not found
- All mutations load → modify in-memory list → save (single-load-modify-save to preserve order)
**ProfileServiceTests.cs** — Replace the stub with real tests using temp file paths:
```csharp
public class ProfileServiceTests : IDisposable
{
private readonly string _tempFile = Path.GetTempFileName();
// Dispose deletes temp file
[Fact]
public async Task SaveAndLoad_RoundTrips_Profiles() { ... }
// etc.
}
```
Tests must use a temp file, not the real user data file. All tests in `[Trait("Category", "Unit")]`.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~ProfileServiceTests" 2>&1 | tail -10</automated>
</verify>
<done>All ProfileServiceTests pass (no skips). JSON output uses camelCase field names matching existing schema. Write-then-replace with SemaphoreSlim implemented.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: SettingsRepository and SettingsService</name>
<files>
SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs,
SharepointToolbox/Services/SettingsService.cs,
SharepointToolbox.Tests/Services/SettingsServiceTests.cs
</files>
<behavior>
- Test: LoadAsync returns default settings (dataFolder = empty string, lang = "en") when file missing
- Test: SaveAsync then LoadAsync round-trips dataFolder and lang values exactly
- Test: Serialized JSON contains "dataFolder" and "lang" keys (not DataFolder/Lang — schema compatibility)
- Test: SaveAsync uses write-then-replace (tmp file created, then moved)
- Test: SettingsService.SetLanguageAsync("fr") persists lang="fr"
- Test: SettingsService.SetDataFolderAsync("C:\\Exports") persists dataFolder path
</behavior>
<action>
**AppSettings model** (add to `Core/Models/AppSettings.cs`):
```csharp
namespace SharepointToolbox.Core.Models;
public class AppSettings
{
public string DataFolder { get; set; } = string.Empty;
public string Lang { get; set; } = "en";
}
```
Note: STJ with `PropertyNamingPolicy.CamelCase` will serialize `DataFolder`→`dataFolder`, `Lang`→`lang`.
**SettingsRepository.cs** — same write-then-replace pattern as ProfileRepository:
- `Task<AppSettings> LoadAsync()` — returns `new AppSettings()` if file missing; throws `InvalidDataException` on corrupt JSON
- `Task SaveAsync(AppSettings settings)` — write-then-replace with `SemaphoreSlim(1)` and camelCase serialization
**SettingsService.cs**:
- Constructor takes `SettingsRepository`
- `Task<AppSettings> GetSettingsAsync()`
- `Task SetLanguageAsync(string cultureCode)` — validates "en" or "fr"; throws `ArgumentException` otherwise
- `Task SetDataFolderAsync(string path)` — saves path (empty string allowed — means default)
**SettingsServiceTests.cs** — Replace stub with real tests using temp file.
All tests in `[Trait("Category", "Unit")]`.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SettingsServiceTests" 2>&1 | tail -10</automated>
</verify>
<done>All SettingsServiceTests pass. AppSettings serializes to dataFolder/lang (camelCase). Default settings returned when file is absent.</done>
</task>
</tasks>
<verification>
- `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "Category=Unit"` — all pass
- JSON output from ProfileRepository contains `"profiles"` root key with `"name"`, `"tenantUrl"`, `"clientId"` field names
- JSON output from SettingsRepository contains `"dataFolder"` and `"lang"` field names
- Both repositories use `SemaphoreSlim(1)` write lock
- Both repositories use write-then-replace (`.tmp` file then `File.Move`)
</verification>
<success_criteria>
Unit tests green for ProfileService and SettingsService. JSON schema compatibility verified by test assertions on serialized output. Write-then-replace pattern protects against crash-corruption.
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation/01-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,266 @@
---
phase: 01-foundation
plan: 04
type: execute
wave: 3
depends_on:
- 01-02
- 01-03
files_modified:
- SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs
- SharepointToolbox/Services/SessionManager.cs
- SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs
- SharepointToolbox.Tests/Auth/SessionManagerTests.cs
autonomous: true
requirements:
- FOUND-03
- FOUND-04
must_haves:
truths:
- "MsalClientFactory creates one IPublicClientApplication per ClientId — never shares instances across tenants"
- "MsalCacheHelper persists token cache to %AppData%\\SharepointToolbox\\auth\\msal_{clientId}.cache"
- "SessionManager.GetOrCreateContextAsync returns a cached ClientContext on second call without interactive login"
- "SessionManager.ClearSessionAsync removes MSAL accounts and disposes ClientContext for the specified tenant"
- "SessionManager is the only class in the codebase holding ClientContext instances"
artifacts:
- path: "SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs"
provides: "Per-ClientId IPublicClientApplication with MsalCacheHelper"
contains: "MsalCacheHelper"
- path: "SharepointToolbox/Services/SessionManager.cs"
provides: "Singleton holding all ClientContext instances and auth state"
exports: ["GetOrCreateContextAsync", "ClearSessionAsync", "IsAuthenticated"]
key_links:
- from: "SharepointToolbox/Services/SessionManager.cs"
to: "SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs"
via: "Injected dependency — SessionManager calls MsalClientFactory.GetOrCreateAsync(clientId)"
pattern: "GetOrCreateAsync"
- from: "SharepointToolbox/Services/SessionManager.cs"
to: "PnP.Framework AuthenticationManager"
via: "CreateWithInteractiveLogin using MSAL PCA"
pattern: "AuthenticationManager"
---
<objective>
Build the authentication layer: MsalClientFactory (per-tenant MSAL client with persistent cache) and SessionManager (singleton holding all live ClientContext instances). This is the security-critical component — one IPublicClientApplication per ClientId, never shared.
Purpose: Every SharePoint operation in Phases 2-4 goes through SessionManager. Getting the per-tenant isolation and token cache correct now prevents auth token bleed between client tenants — a critical security property for MSP use.
Output: MsalClientFactory + SessionManager + unit tests validating per-tenant isolation.
</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/phases/01-foundation/01-CONTEXT.md
@.planning/phases/01-foundation/01-RESEARCH.md
@.planning/phases/01-foundation/01-02-SUMMARY.md
@.planning/phases/01-foundation/01-03-SUMMARY.md
<interfaces>
<!-- From Core/Models/TenantProfile.cs (plan 01-02) -->
```csharp
public class TenantProfile
{
public string Name { get; set; }
public string TenantUrl { get; set; }
public string ClientId { get; set; }
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: MsalClientFactory — per-ClientId PCA with MsalCacheHelper</name>
<files>
SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs,
SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs
</files>
<behavior>
- Test: GetOrCreateAsync("clientA") and GetOrCreateAsync("clientA") return the same instance (no duplicate creation)
- Test: GetOrCreateAsync("clientA") and GetOrCreateAsync("clientB") return different instances (per-tenant isolation)
- Test: Concurrent calls to GetOrCreateAsync with same clientId do not create duplicate instances (SemaphoreSlim)
- Test: Cache directory path resolves to %AppData%\SharepointToolbox\auth\ (not a hardcoded path)
</behavior>
<action>
Create `Infrastructure/Auth/` directory.
**MsalClientFactory.cs** — implement exactly as per research Pattern 3:
```csharp
namespace SharepointToolbox.Infrastructure.Auth;
public class MsalClientFactory
{
private readonly Dictionary<string, IPublicClientApplication> _clients = new();
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly string _cacheDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SharepointToolbox", "auth");
public async Task<IPublicClientApplication> GetOrCreateAsync(string clientId)
{
await _lock.WaitAsync();
try
{
if (_clients.TryGetValue(clientId, out var existing))
return existing;
var storageProps = new StorageCreationPropertiesBuilder(
$"msal_{clientId}.cache", _cacheDir)
.Build();
var pca = PublicClientApplicationBuilder
.Create(clientId)
.WithDefaultRedirectUri()
.WithLegacyCacheCompatibility(false)
.Build();
var helper = await MsalCacheHelper.CreateAsync(storageProps);
helper.RegisterCache(pca.UserTokenCache);
_clients[clientId] = pca;
return pca;
}
finally { _lock.Release(); }
}
}
```
**MsalClientFactoryTests.cs** — Replace stub. Tests for per-ClientId isolation and idempotency.
Since MsalCacheHelper creates real files, tests must use a temp directory and clean up.
Use `[Trait("Category", "Unit")]` on all tests.
Mock or subclass `MsalClientFactory` for the concurrent test to avoid real MSAL overhead.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~MsalClientFactoryTests" 2>&1 | tail -10</automated>
</verify>
<done>MsalClientFactoryTests pass. Different clientIds produce different instances. Same clientId produces same instance on second call.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: SessionManager — singleton ClientContext holder</name>
<files>
SharepointToolbox/Services/SessionManager.cs,
SharepointToolbox.Tests/Auth/SessionManagerTests.cs
</files>
<behavior>
- Test: IsAuthenticated(tenantUrl) returns false before any authentication
- Test: After GetOrCreateContextAsync succeeds, IsAuthenticated(tenantUrl) returns true
- Test: ClearSessionAsync removes authentication state for the specified tenant
- Test: ClearSessionAsync on unknown tenantUrl does not throw (idempotent)
- Test: ClientContext is disposed on ClearSessionAsync (verify via mock/wrapper)
- Test: GetOrCreateContextAsync throws ArgumentException for null/empty tenantUrl or clientId
</behavior>
<action>
**SessionManager.cs** — singleton, owns all ClientContext instances:
```csharp
namespace SharepointToolbox.Services;
public class SessionManager
{
private readonly MsalClientFactory _msalFactory;
private readonly Dictionary<string, ClientContext> _contexts = new();
private readonly SemaphoreSlim _lock = new(1, 1);
public SessionManager(MsalClientFactory msalFactory)
{
_msalFactory = msalFactory;
}
public bool IsAuthenticated(string tenantUrl) =>
_contexts.ContainsKey(NormalizeUrl(tenantUrl));
/// <summary>
/// Returns existing ClientContext or creates a new one via interactive MSAL login.
/// Only SessionManager holds ClientContext instances — never return to callers for storage.
/// </summary>
public async Task<ClientContext> GetOrCreateContextAsync(
TenantProfile profile,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl);
ArgumentException.ThrowIfNullOrEmpty(profile.ClientId);
var key = NormalizeUrl(profile.TenantUrl);
await _lock.WaitAsync(ct);
try
{
if (_contexts.TryGetValue(key, out var existing))
return existing;
var pca = await _msalFactory.GetOrCreateAsync(profile.ClientId);
var authManager = AuthenticationManager.CreateWithInteractiveLogin(
profile.ClientId,
(url, port) =>
{
// WAM/browser-based interactive login
return pca.AcquireTokenInteractive(
new[] { "https://graph.microsoft.com/.default" })
.ExecuteAsync(ct);
});
var ctx = await authManager.GetContextAsync(profile.TenantUrl);
_contexts[key] = ctx;
return ctx;
}
finally { _lock.Release(); }
}
/// <summary>
/// Clears MSAL accounts and disposes the ClientContext for the given tenant.
/// Called by "Clear Session" button and on tenant profile deletion.
/// </summary>
public async Task ClearSessionAsync(string tenantUrl)
{
var key = NormalizeUrl(tenantUrl);
await _lock.WaitAsync();
try
{
if (_contexts.TryGetValue(key, out var ctx))
{
ctx.Dispose();
_contexts.Remove(key);
}
}
finally { _lock.Release(); }
}
private static string NormalizeUrl(string url) =>
url.TrimEnd('/').ToLowerInvariant();
}
```
Note on PnP AuthenticationManager: The exact API for `CreateWithInteractiveLogin` with MSAL PCA may vary in PnP.Framework 1.18.0. The implementation above is a skeleton — executor should verify the PnP API surface and adjust accordingly. The key invariant is: `MsalClientFactory.GetOrCreateAsync` is called first, then PnP creates the context using the returned PCA. Do NOT call `PublicClientApplicationBuilder.Create` directly in SessionManager.
**SessionManagerTests.cs** — Replace stub. Use Moq to mock `MsalClientFactory`.
Test `IsAuthenticated`, `ClearSessionAsync` idempotency, and argument validation.
Interactive login cannot be tested in unit tests — mark `GetOrCreateContextAsync_CreatesContext` as `[Fact(Skip = "Requires interactive MSAL — integration test only")]`.
All other tests in `[Trait("Category", "Unit")]`.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SessionManagerTests" 2>&1 | tail -10</automated>
</verify>
<done>SessionManagerTests pass (interactive login test skipped). IsAuthenticated, ClearSessionAsync, and argument validation tests are green. SessionManager is the only holder of ClientContext.</done>
</task>
</tasks>
<verification>
- `dotnet test --filter "Category=Unit"` passes
- MsalClientFactory._clients dictionary holds one entry per unique clientId
- SessionManager.ClearSessionAsync calls ctx.Dispose() (verified via test)
- No class outside SessionManager stores a ClientContext reference
</verification>
<success_criteria>
Auth layer unit tests green. Per-tenant isolation (one PCA per ClientId, one context per tenantUrl) confirmed by tests. SessionManager is the single source of truth for authenticated connections.
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation/01-04-SUMMARY.md`
</output>

View File

@@ -0,0 +1,253 @@
---
phase: 01-foundation
plan: 05
type: execute
wave: 3
depends_on:
- 01-02
files_modified:
- SharepointToolbox/Localization/TranslationSource.cs
- SharepointToolbox/Localization/Strings.resx
- SharepointToolbox/Localization/Strings.fr.resx
- SharepointToolbox.Tests/Localization/TranslationSourceTests.cs
- SharepointToolbox.Tests/Integration/LoggingIntegrationTests.cs
autonomous: true
requirements:
- FOUND-08
- FOUND-09
must_haves:
truths:
- "TranslationSource.Instance[key] returns the EN string for English culture"
- "Setting TranslationSource.Instance.CurrentCulture to 'fr' changes string lookup without app restart"
- "PropertyChanged fires with empty string key (signals all properties changed) on culture switch"
- "Serilog writes to rolling daily log file at %AppData%\\SharepointToolbox\\logs\\app-{date}.log"
- "Serilog ILogger<T> is injectable via DI — does not use static Log.Logger directly in services"
- "LoggingIntegrationTests verify a log file is created and contains the written message"
artifacts:
- path: "SharepointToolbox/Localization/TranslationSource.cs"
provides: "Singleton INotifyPropertyChanged string lookup for runtime culture switching"
contains: "PropertyChangedEventArgs(string.Empty)"
- path: "SharepointToolbox/Localization/Strings.resx"
provides: "EN default resource file with all Phase 1 UI strings"
- path: "SharepointToolbox/Localization/Strings.fr.resx"
provides: "FR overlay — all keys present, values stubbed with EN text"
key_links:
- from: "SharepointToolbox/Localization/TranslationSource.cs"
to: "SharepointToolbox/Localization/Strings.resx"
via: "ResourceManager from Strings class"
pattern: "Strings.ResourceManager"
- from: "MainWindow.xaml (plan 01-06)"
to: "SharepointToolbox/Localization/TranslationSource.cs"
via: "XAML binding: Source={x:Static loc:TranslationSource.Instance}, Path=[key]"
pattern: "TranslationSource.Instance"
---
<objective>
Build the logging infrastructure and dynamic localization system. Serilog wired into Generic Host. TranslationSource singleton enabling runtime culture switching without restart.
Purpose: Every feature phase needs ILogger<T> injection and localizable strings. The TranslationSource pattern (INotifyPropertyChanged indexer binding) is the only approach that refreshes WPF bindings at runtime — standard x:Static resx bindings are evaluated once at startup.
Output: TranslationSource + EN/FR resx files + Serilog integration + unit/integration tests.
</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/phases/01-foundation/01-CONTEXT.md
@.planning/phases/01-foundation/01-RESEARCH.md
@.planning/phases/01-foundation/01-02-SUMMARY.md
<interfaces>
<!-- From Core/Messages/LanguageChangedMessage.cs (plan 01-02) -->
```csharp
public sealed class LanguageChangedMessage : ValueChangedMessage<string>
{
public LanguageChangedMessage(string cultureCode) : base(cultureCode) { }
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: TranslationSource singleton + EN/FR resx files</name>
<files>
SharepointToolbox/Localization/TranslationSource.cs,
SharepointToolbox/Localization/Strings.resx,
SharepointToolbox/Localization/Strings.fr.resx,
SharepointToolbox.Tests/Localization/TranslationSourceTests.cs
</files>
<behavior>
- Test: TranslationSource.Instance["app.title"] returns "SharePoint Toolbox" (EN default)
- Test: After setting CurrentCulture to fr-FR, TranslationSource.Instance["app.title"] returns FR value (or EN fallback if FR not defined)
- Test: Changing CurrentCulture fires PropertyChanged with EventArgs having empty string PropertyName
- Test: Setting same culture twice does NOT fire PropertyChanged (equality check)
- Test: Missing key returns "[key]" not null (prevents NullReferenceException in bindings)
- Test: TranslationSource.Instance is same instance on multiple accesses (singleton)
</behavior>
<action>
Create `Localization/` directory.
**TranslationSource.cs** — implement exactly as per research Pattern 4:
```csharp
namespace SharepointToolbox.Localization;
public class TranslationSource : INotifyPropertyChanged
{
public static readonly TranslationSource Instance = new();
private ResourceManager _resourceManager = Strings.ResourceManager;
private CultureInfo _currentCulture = CultureInfo.CurrentUICulture;
public string this[string key] =>
_resourceManager.GetString(key, _currentCulture) ?? $"[{key}]";
public CultureInfo CurrentCulture
{
get => _currentCulture;
set
{
if (Equals(_currentCulture, value)) return;
_currentCulture = value;
Thread.CurrentThread.CurrentUICulture = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
```
**Strings.resx** — Create with ResXResourceWriter or manually as XML. Include ALL Phase 1 UI strings. Key naming mirrors existing PowerShell convention (see CONTEXT.md).
Required keys (minimum set for Phase 1 — add more as needed during shell implementation):
```
app.title = SharePoint Toolbox
toolbar.connect = Connect
toolbar.manage = Manage Profiles...
toolbar.clear = Clear Session
tab.permissions = Permissions
tab.storage = Storage
tab.search = File Search
tab.duplicates = Duplicates
tab.templates = Templates
tab.bulk = Bulk Operations
tab.structure = Folder Structure
tab.settings = Settings
tab.comingsoon = Coming soon
btn.cancel = Cancel
settings.language = Language
settings.lang.en = English
settings.lang.fr = French
settings.folder = Data output folder
settings.browse = Browse...
profile.name = Profile name
profile.url = Tenant URL
profile.clientid = Client ID
profile.add = Add
profile.rename = Rename
profile.delete = Delete
status.ready = Ready
status.cancelled = Operation cancelled
err.auth.failed = Authentication failed. Check tenant URL and Client ID.
err.generic = An error occurred. See log for details.
```
**Strings.fr.resx** — All same keys, values stubbed with EN text. A comment `<!-- FR stub — Phase 5 -->` on each value is acceptable. FR completeness is Phase 5.
**TranslationSourceTests.cs** — Replace stub with real tests.
All tests in `[Trait("Category", "Unit")]`.
TranslationSource.Instance is a static singleton — reset culture to EN in test teardown to avoid test pollution.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~TranslationSourceTests" 2>&1 | tail -10</automated>
</verify>
<done>TranslationSourceTests pass. Missing key returns "[key]". Culture switch fires PropertyChanged with empty property name. Strings.resx contains all required keys.</done>
</task>
<task type="auto">
<name>Task 2: Serilog integration tests and App.xaml.cs logging wiring verification</name>
<files>
SharepointToolbox.Tests/Integration/LoggingIntegrationTests.cs
</files>
<action>
The Serilog file sink is already wired in App.xaml.cs (plan 01-01). This task writes an integration test to verify the wiring produces an actual log file and that the LogPanelSink (from plan 01-02) can be instantiated without errors.
**LoggingIntegrationTests.cs** — Replace stub:
```csharp
[Trait("Category", "Integration")]
public class LoggingIntegrationTests : IDisposable
{
private readonly string _tempLogDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
[Fact]
public async Task Serilog_WritesLogFile_WhenMessageLogged()
{
Directory.CreateDirectory(_tempLogDir);
var logFile = Path.Combine(_tempLogDir, "test-.log");
var logger = new LoggerConfiguration()
.WriteTo.File(logFile, rollingInterval: RollingInterval.Day)
.CreateLogger();
logger.Information("Test log message {Value}", 42);
await logger.DisposeAsync();
var files = Directory.GetFiles(_tempLogDir, "*.log");
Assert.Single(files);
var content = await File.ReadAllTextAsync(files[0]);
Assert.Contains("Test log message 42", content);
}
[Fact]
public void LogPanelSink_CanBeInstantiated_WithRichTextBox()
{
// Verify the sink type instantiates without throwing
// Cannot test actual UI writes without STA thread — this is structural smoke only
var sinkType = typeof(LogPanelSink);
Assert.NotNull(sinkType);
Assert.True(typeof(ILogEventSink).IsAssignableFrom(sinkType));
}
public void Dispose()
{
if (Directory.Exists(_tempLogDir))
Directory.Delete(_tempLogDir, recursive: true);
}
}
```
Note: `LogPanelSink` instantiation test avoids creating a real `RichTextBox` (requires STA thread). It only verifies the type implements `ILogEventSink`. Full UI-thread integration is verified in the manual checkpoint (plan 01-08).
Also update `App.xaml.cs` RegisterServices to add `LogPanelSink` registration comment for plan 01-06:
```csharp
// LogPanelSink registered in plan 01-06 after MainWindow is created
// (requires RichTextBox reference from MainWindow)
```
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~LoggingIntegrationTests" 2>&1 | tail -10</automated>
</verify>
<done>LoggingIntegrationTests pass. Log file created in temp directory with expected content. LogPanelSink type check passes.</done>
</task>
</tasks>
<verification>
- `dotnet test --filter "Category=Unit"` and `--filter "Category=Integration"` both pass
- Strings.resx contains all keys listed in the action section
- Strings.fr.resx contains same key set (verified by comparing key counts)
- TranslationSource.Instance is not null
- PropertyChanged fires with `string.Empty` PropertyName on culture change
</verification>
<success_criteria>
Localization system supports runtime culture switching confirmed by tests. All Phase 1 UI strings defined in EN resx. FR resx has same key set (stubbed). Serilog integration test verifies log file creation.
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation/01-05-SUMMARY.md`
</output>

View File

@@ -0,0 +1,393 @@
---
phase: 01-foundation
plan: 06
type: execute
wave: 4
depends_on:
- 01-03
- 01-04
- 01-05
files_modified:
- SharepointToolbox/ViewModels/FeatureViewModelBase.cs
- SharepointToolbox/ViewModels/MainWindowViewModel.cs
- SharepointToolbox/ViewModels/ProfileManagementViewModel.cs
- SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs
- SharepointToolbox/Views/MainWindow.xaml
- SharepointToolbox/Views/MainWindow.xaml.cs
- SharepointToolbox/App.xaml.cs
- SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs
autonomous: true
requirements:
- FOUND-01
- FOUND-05
- FOUND-06
- FOUND-07
must_haves:
truths:
- "MainWindow displays: top toolbar, center TabControl with 8 feature tabs, bottom RichTextBox log panel (150px), bottom StatusBar"
- "Toolbar ComboBox bound to TenantProfiles ObservableCollection; selecting a different item triggers TenantSwitchedMessage"
- "FeatureViewModelBase provides CancellationTokenSource lifecycle, IsRunning, IProgress<OperationProgress>, OperationCanceledException handling"
- "Global exception handlers (DispatcherUnhandledException + TaskScheduler.UnobservedTaskException) funnel to log panel + MessageBox"
- "LogPanelSink wired to MainWindow RichTextBox after Generic Host starts"
- "FeatureViewModelBaseTests: progress reporting, cancellation, and error handling all green"
artifacts:
- path: "SharepointToolbox/ViewModels/FeatureViewModelBase.cs"
provides: "Base class for all feature ViewModels with canonical async command pattern"
contains: "CancellationTokenSource"
- path: "SharepointToolbox/ViewModels/MainWindowViewModel.cs"
provides: "Shell ViewModel with TenantProfiles and connection state"
contains: "ObservableCollection"
- path: "SharepointToolbox/Views/MainWindow.xaml"
provides: "WPF shell with toolbar, TabControl, log panel, StatusBar"
contains: "RichTextBox"
key_links:
- from: "SharepointToolbox/Views/MainWindow.xaml"
to: "SharepointToolbox/ViewModels/MainWindowViewModel.cs"
via: "DataContext binding in MainWindow.xaml.cs constructor"
pattern: "DataContext"
- from: "SharepointToolbox/App.xaml.cs"
to: "SharepointToolbox/Infrastructure/Logging/LogPanelSink.cs"
via: "LoggerConfiguration.WriteTo.Sink(new LogPanelSink(mainWindow.LogPanel))"
pattern: "LogPanelSink"
- from: "SharepointToolbox/ViewModels/MainWindowViewModel.cs"
to: "SharepointToolbox/Core/Messages/TenantSwitchedMessage.cs"
via: "WeakReferenceMessenger.Default.Send on ComboBox selection change"
pattern: "TenantSwitchedMessage"
---
<objective>
Build the WPF shell — MainWindow XAML + all ViewModels. Wire LogPanelSink to the RichTextBox. Implement FeatureViewModelBase with the canonical async pattern. Register global exception handlers.
Purpose: This is the first time the application visually exists. All subsequent feature plans add TabItems to the already-wired TabControl.
Output: Runnable WPF application showing the shell with placeholder tabs, log panel, and status bar.
</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/phases/01-foundation/01-CONTEXT.md
@.planning/phases/01-foundation/01-RESEARCH.md
@.planning/phases/01-foundation/01-03-SUMMARY.md
@.planning/phases/01-foundation/01-04-SUMMARY.md
@.planning/phases/01-foundation/01-05-SUMMARY.md
<interfaces>
<!-- From Core/Models (plan 01-02) -->
```csharp
public class TenantProfile { string Name; string TenantUrl; string ClientId; }
public record OperationProgress(int Current, int Total, string Message)
```
<!-- From Core/Messages (plan 01-02) -->
```csharp
public sealed class TenantSwitchedMessage : ValueChangedMessage<TenantProfile>
public sealed class LanguageChangedMessage : ValueChangedMessage<string>
```
<!-- From Services (plans 01-03, 01-04) -->
```csharp
// ProfileService: GetProfilesAsync(), AddProfileAsync(), RenameProfileAsync(), DeleteProfileAsync()
// SessionManager: IsAuthenticated(url), GetOrCreateContextAsync(profile, ct), ClearSessionAsync(url)
// SettingsService: GetSettingsAsync(), SetLanguageAsync(code), SetDataFolderAsync(path)
```
<!-- From Localization (plan 01-05) -->
```csharp
// TranslationSource.Instance[key] — binding: Source={x:Static loc:TranslationSource.Instance}, Path=[key]
```
<!-- Shell layout (locked in CONTEXT.md) -->
// Toolbar (L→R): ComboBox (220px) → Button "Connect" → Button "Manage Profiles..." → separator → Button "Clear Session"
// TabControl: 8 tabs (Permissions, Storage, File Search, Duplicates, Templates, Bulk Operations, Folder Structure, Settings)
// Log panel: RichTextBox, 150px tall, always visible, x:Name="LogPanel"
// StatusBar: tenant name | operation status | progress %
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: FeatureViewModelBase + unit tests</name>
<files>
SharepointToolbox/ViewModels/FeatureViewModelBase.cs,
SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs
</files>
<behavior>
- Test: IsRunning is true while operation executes, false after completion
- Test: ProgressValue and StatusMessage update via IProgress<OperationProgress> on UI thread
- Test: Calling CancelCommand during operation causes StatusMessage to show cancellation message
- Test: OperationCanceledException is caught gracefully — IsRunning becomes false, no exception propagates
- Test: Exception during operation sets StatusMessage to error text — IsRunning becomes false
- Test: RunCommand cannot be invoked while IsRunning (CanExecute returns false)
</behavior>
<action>
Create `ViewModels/` directory.
**FeatureViewModelBase.cs** — implement exactly as per research Pattern 2:
```csharp
namespace SharepointToolbox.ViewModels;
public abstract class FeatureViewModelBase : ObservableRecipient
{
private CancellationTokenSource? _cts;
private readonly ILogger<FeatureViewModelBase> _logger;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(CancelCommand))]
private bool _isRunning;
[ObservableProperty]
private string _statusMessage = string.Empty;
[ObservableProperty]
private int _progressValue;
public IAsyncRelayCommand RunCommand { get; }
public RelayCommand CancelCommand { get; }
protected FeatureViewModelBase(ILogger<FeatureViewModelBase> logger)
{
_logger = logger;
RunCommand = new AsyncRelayCommand(ExecuteAsync, () => !IsRunning);
CancelCommand = new RelayCommand(() => _cts?.Cancel(), () => IsRunning);
IsActive = true; // Activates ObservableRecipient for WeakReferenceMessenger
}
private async Task ExecuteAsync()
{
_cts = new CancellationTokenSource();
IsRunning = true;
StatusMessage = string.Empty;
ProgressValue = 0;
try
{
var progress = new Progress<OperationProgress>(p =>
{
ProgressValue = p.Total > 0 ? (int)(100.0 * p.Current / p.Total) : 0;
StatusMessage = p.Message;
WeakReferenceMessenger.Default.Send(new ProgressUpdatedMessage(p));
});
await RunOperationAsync(_cts.Token, progress);
}
catch (OperationCanceledException)
{
StatusMessage = TranslationSource.Instance["status.cancelled"];
_logger.LogInformation("Operation cancelled by user.");
}
catch (Exception ex)
{
StatusMessage = $"{TranslationSource.Instance["err.generic"]} {ex.Message}";
_logger.LogError(ex, "Operation failed.");
}
finally
{
IsRunning = false;
_cts?.Dispose();
_cts = null;
}
}
protected abstract Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress);
protected override void OnActivated()
{
Messenger.Register<TenantSwitchedMessage>(this, (r, m) => r.OnTenantSwitched(m.Value));
}
protected virtual void OnTenantSwitched(TenantProfile profile)
{
// Derived classes override to reset their state
}
}
```
Also create `Core/Messages/ProgressUpdatedMessage.cs` (needed for StatusBar update):
```csharp
public sealed class ProgressUpdatedMessage : ValueChangedMessage<OperationProgress>
{
public ProgressUpdatedMessage(OperationProgress progress) : base(progress) { }
}
```
**FeatureViewModelBaseTests.cs** — Replace stub. Use a concrete test subclass:
```csharp
private class TestViewModel : FeatureViewModelBase
{
public TestViewModel(ILogger<FeatureViewModelBase> logger) : base(logger) { }
public Func<CancellationToken, IProgress<OperationProgress>, Task>? OperationFunc { get; set; }
protected override Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> p)
=> OperationFunc?.Invoke(ct, p) ?? Task.CompletedTask;
}
```
All tests in `[Trait("Category", "Unit")]`.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~FeatureViewModelBaseTests" 2>&1 | tail -10</automated>
</verify>
<done>All FeatureViewModelBaseTests pass. IsRunning lifecycle correct. Cancellation handled gracefully. Exception caught with error message set.</done>
</task>
<task type="auto">
<name>Task 2: MainWindowViewModel, shell ViewModels, and MainWindow XAML</name>
<files>
SharepointToolbox/ViewModels/MainWindowViewModel.cs,
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs,
SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs,
SharepointToolbox/Views/MainWindow.xaml,
SharepointToolbox/Views/MainWindow.xaml.cs,
SharepointToolbox/App.xaml.cs
</files>
<action>
Create `ViewModels/Tabs/` and `Views/` directories.
**MainWindowViewModel.cs**:
```csharp
[ObservableProperty] private TenantProfile? _selectedProfile;
[ObservableProperty] private string _connectionStatus = "Not connected";
public ObservableCollection<TenantProfile> TenantProfiles { get; } = new();
// ConnectCommand: calls SessionManager.GetOrCreateContextAsync(SelectedProfile)
// ClearSessionCommand: calls SessionManager.ClearSessionAsync(SelectedProfile.TenantUrl)
// ManageProfilesCommand: opens ProfileManagementDialog as modal
// OnSelectedProfileChanged (partial): sends TenantSwitchedMessage via WeakReferenceMessenger
// LoadProfilesAsync: called on startup, loads from ProfileService
```
**ProfileManagementViewModel.cs**: Wraps ProfileService for dialog binding.
- `ObservableCollection<TenantProfile> Profiles`
- `AddCommand`, `RenameCommand`, `DeleteCommand`
- Validates inputs (non-empty Name, valid URL format, non-empty ClientId)
**SettingsViewModel.cs** (inherits FeatureViewModelBase):
- `string SelectedLanguage` bound to language ComboBox
- `string DataFolder` bound to folder TextBox
- `BrowseFolderCommand` opens FolderBrowserDialog
- On language change: updates `TranslationSource.Instance.CurrentCulture` + calls `SettingsService.SetLanguageAsync`
- `RunOperationAsync`: not applicable — stub throws `NotSupportedException` (Settings tab has no long-running operation)
**MainWindow.xaml** — Full shell layout as locked in CONTEXT.md:
```xml
<Window Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}"
MinWidth="900" MinHeight="600">
<DockPanel>
<!-- Toolbar -->
<ToolBar DockPanel.Dock="Top">
<ComboBox Width="220" ItemsSource="{Binding TenantProfiles}"
SelectedItem="{Binding SelectedProfile}"
DisplayMemberPath="Name" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.connect]}"
Command="{Binding ConnectCommand}" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.manage]}"
Command="{Binding ManageProfilesCommand}" />
<Separator />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.clear]}"
Command="{Binding ClearSessionCommand}" />
</ToolBar>
<!-- StatusBar -->
<StatusBar DockPanel.Dock="Bottom" Height="24">
<StatusBarItem Content="{Binding SelectedProfile.Name}" />
<Separator />
<StatusBarItem Content="{Binding ConnectionStatus}" />
</StatusBar>
<!-- Log Panel -->
<RichTextBox x:Name="LogPanel" DockPanel.Dock="Bottom" Height="150"
IsReadOnly="True" VerticalScrollBarVisibility="Auto"
Background="Black" Foreground="LimeGreen"
FontFamily="Consolas" FontSize="11" />
<!-- TabControl -->
<TabControl>
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.permissions]}">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.comingsoon]}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</TabItem>
<!-- Repeat for: Storage, File Search, Duplicates, Templates, Bulk Operations, Folder Structure -->
<!-- Settings tab binds to SettingsView (plan 01-07) -->
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.settings]}">
<TextBlock Text="Settings (plan 01-07)" HorizontalAlignment="Center" VerticalAlignment="Center" />
</TabItem>
</TabControl>
</DockPanel>
</Window>
```
**MainWindow.xaml.cs**: Constructor receives `MainWindowViewModel` via DI constructor injection. Sets `DataContext = viewModel`. Calls `viewModel.LoadProfilesAsync()` in `Loaded` event.
**App.xaml.cs** — Update RegisterServices:
```csharp
services.AddSingleton<MsalClientFactory>();
services.AddSingleton<SessionManager>();
services.AddSingleton<ProfileService>();
services.AddSingleton<SettingsService>();
services.AddSingleton<MainWindowViewModel>();
services.AddTransient<ProfileManagementViewModel>();
services.AddTransient<SettingsViewModel>();
services.AddSingleton<MainWindow>();
```
Wire LogPanelSink AFTER MainWindow is resolved (it needs the RichTextBox reference):
```csharp
host.Start();
App app = new();
app.InitializeComponent();
var mainWindow = host.Services.GetRequiredService<MainWindow>();
// Wire LogPanelSink now that we have the RichTextBox
Log.Logger = new LoggerConfiguration()
.WriteTo.File(/* rolling file path */)
.WriteTo.Sink(new LogPanelSink(mainWindow.LogPanel))
.CreateLogger();
app.MainWindow = mainWindow;
app.MainWindow.Visibility = Visibility.Visible;
```
**Global exception handlers** in App.xaml.cs (after app created):
```csharp
app.DispatcherUnhandledException += (s, e) =>
{
Log.Fatal(e.Exception, "Unhandled UI exception");
MessageBox.Show(
$"A fatal error occurred:\n{e.Exception.Message}\n\nCheck log for details.",
"Fatal Error", MessageBoxButton.OK, MessageBoxImage.Error);
e.Handled = true;
};
TaskScheduler.UnobservedTaskException += (s, e) =>
{
Log.Fatal(e.Exception, "Unobserved task exception");
e.SetObserved();
};
```
Run `dotnet build SharepointToolbox/SharepointToolbox.csproj` — fix any XAML or CS compilation 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 with 0 errors. MainWindow.xaml contains RichTextBox x:Name="LogPanel". All 8 tab headers use TranslationSource bindings. Global exception handlers registered in App.xaml.cs.</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox.sln` passes with 0 errors
- `dotnet test --filter "Category=Unit"` all pass
- MainWindow.xaml contains `x:Name="LogPanel"` RichTextBox
- App.xaml.cs registers `DispatcherUnhandledException` and `TaskScheduler.UnobservedTaskException`
- FeatureViewModelBase contains no `async void` methods (anti-pattern violation)
- ObservableCollection is never modified from Task.Run (pattern 7 compliance)
</verification>
<success_criteria>
Application compiles and launches to a visible WPF shell. FeatureViewModelBase tests green. All ViewModels registered in DI. Log panel wired to Serilog.
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation/01-06-SUMMARY.md`
</output>

View File

@@ -0,0 +1,271 @@
---
phase: 01-foundation
plan: 07
type: execute
wave: 5
depends_on:
- 01-06
files_modified:
- SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml
- SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml.cs
- SharepointToolbox/Views/Tabs/SettingsView.xaml
- SharepointToolbox/Views/Tabs/SettingsView.xaml.cs
- SharepointToolbox/Views/MainWindow.xaml
autonomous: true
requirements:
- FOUND-02
- FOUND-09
- FOUND-12
must_haves:
truths:
- "ProfileManagementDialog opens as a modal window from the Manage Profiles button"
- "User can add a new profile (Name + Tenant URL + Client ID fields) and it appears in the toolbar ComboBox"
- "User can rename and delete existing profiles in the dialog"
- "SettingsView has a language ComboBox (English / French) and a data folder TextBox with Browse button"
- "Changing language in SettingsView switches the UI language immediately without restart"
- "Data folder setting persists to Sharepoint_Settings.json"
artifacts:
- path: "SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml"
provides: "Modal dialog for profile CRUD"
contains: "ProfileManagementViewModel"
- path: "SharepointToolbox/Views/Tabs/SettingsView.xaml"
provides: "Settings tab content with language and folder controls"
contains: "TranslationSource"
key_links:
- from: "SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml"
to: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
via: "DataContext = viewModel (constructor injected)"
pattern: "DataContext"
- from: "SharepointToolbox/Views/Tabs/SettingsView.xaml"
to: "SharepointToolbox/Localization/TranslationSource.cs"
via: "Language ComboBox selection sets TranslationSource.Instance.CurrentCulture"
pattern: "TranslationSource"
- from: "SharepointToolbox/Views/MainWindow.xaml"
to: "SharepointToolbox/Views/Tabs/SettingsView.xaml"
via: "Settings TabItem ContentTemplate or direct UserControl reference"
pattern: "SettingsView"
---
<objective>
Build the two user-facing views completing Phase 1 UX: ProfileManagementDialog (profile CRUD modal) and SettingsView (language + data folder). Wire SettingsView into the MainWindow Settings tab.
Purpose: These are the last two user-visible pieces before the visual checkpoint. After this plan the application is functional enough for a human to create a tenant profile, connect, and switch language.
Output: ProfileManagementDialog + SettingsView wired into the shell.
</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/phases/01-foundation/01-CONTEXT.md
@.planning/phases/01-foundation/01-06-SUMMARY.md
<interfaces>
<!-- From ProfileManagementViewModel (plan 01-06) -->
```csharp
public class ProfileManagementViewModel : ObservableObject
{
public ObservableCollection<TenantProfile> Profiles { get; }
public TenantProfile? SelectedProfile { get; set; }
public string NewName { get; set; }
public string NewTenantUrl { get; set; }
public string NewClientId { get; set; }
public IAsyncRelayCommand AddCommand { get; }
public IAsyncRelayCommand RenameCommand { get; }
public IAsyncRelayCommand DeleteCommand { get; }
}
```
<!-- From SettingsViewModel (plan 01-06) -->
```csharp
public class SettingsViewModel : FeatureViewModelBase
{
public string SelectedLanguage { get; set; } // "en" or "fr"
public string DataFolder { get; set; }
public RelayCommand BrowseFolderCommand { get; }
}
```
<!-- Locked UI spec from CONTEXT.md -->
// ProfileManagementDialog: modal Window, fields: Name + Tenant URL + Client ID
// Profile fields: { name, tenantUrl, clientId } — JSON schema
// SettingsView: language ComboBox (English/French) + DataFolder TextBox + Browse button
// Language switch: immediate, no restart, via TranslationSource.Instance.CurrentCulture
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: ProfileManagementDialog XAML and code-behind</name>
<files>
SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml,
SharepointToolbox/Views/Dialogs/ProfileManagementDialog.xaml.cs
</files>
<action>
Create `Views/Dialogs/` directory.
**ProfileManagementDialog.xaml** — modal Window (not UserControl):
```xml
<Window x:Class="SharepointToolbox.Views.Dialogs.ProfileManagementDialog"
Title="Manage Profiles" Width="500" Height="480"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
ResizeMode="NoResize">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <!-- Existing profiles list -->
<RowDefinition Height="*" />
<RowDefinition Height="Auto" /> <!-- Add/Edit fields -->
<RowDefinition Height="Auto" /> <!-- Action buttons -->
</Grid.RowDefinitions>
<!-- Profile list -->
<Label Content="Profiles" Grid.Row="0" />
<ListBox Grid.Row="1" Margin="0,0,0,8"
ItemsSource="{Binding Profiles}"
SelectedItem="{Binding SelectedProfile}"
DisplayMemberPath="Name" />
<!-- Input fields -->
<Grid Grid.Row="2" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.name]}"
Grid.Row="0" Grid.Column="0" />
<TextBox Text="{Binding NewName, UpdateSourceTrigger=PropertyChanged}"
Grid.Row="0" Grid.Column="1" Margin="0,2" />
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.url]}"
Grid.Row="1" Grid.Column="0" />
<TextBox Text="{Binding NewTenantUrl, UpdateSourceTrigger=PropertyChanged}"
Grid.Row="1" Grid.Column="1" Margin="0,2" />
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid]}"
Grid.Row="2" Grid.Column="0" />
<TextBox Text="{Binding NewClientId, UpdateSourceTrigger=PropertyChanged}"
Grid.Row="2" Grid.Column="1" Margin="0,2" />
</Grid>
<!-- Buttons -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.add]}"
Command="{Binding AddCommand}" Width="60" Margin="4,0" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.rename]}"
Command="{Binding RenameCommand}" Width="60" Margin="4,0" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.delete]}"
Command="{Binding DeleteCommand}" Width="60" Margin="4,0" />
<Button Content="Close" Width="60" Margin="4,0"
Click="CloseButton_Click" IsCancel="True" />
</StackPanel>
</Grid>
</Window>
```
**ProfileManagementDialog.xaml.cs**:
- Constructor receives `ProfileManagementViewModel` via DI (register as `Transient` in App.xaml.cs — already done in plan 01-06)
- Sets `DataContext = viewModel`
- `CloseButton_Click`: calls `this.Close()`
- `Owner` set by caller (`MainWindowViewModel.ManageProfilesCommand` opens as `new ProfileManagementDialog { Owner = Application.Current.MainWindow }.ShowDialog()`)
After adding: the Add command in `ProfileManagementViewModel` must also trigger `MainWindowViewModel.TenantProfiles` refresh. Implement by having `ProfileManagementViewModel` accept a callback or raise an event. The simplest approach: `MainWindowViewModel.ManageProfilesCommand` reloads profiles after the dialog closes (dialog is modal — `ShowDialog()` blocks until closed).
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj 2>&1 | tail -5</automated>
</verify>
<done>Build succeeds. ProfileManagementDialog.xaml contains all three input fields (Name, Tenant URL, Client ID). All labels use TranslationSource bindings.</done>
</task>
<task type="auto">
<name>Task 2: SettingsView XAML and MainWindow Settings tab wiring</name>
<files>
SharepointToolbox/Views/Tabs/SettingsView.xaml,
SharepointToolbox/Views/Tabs/SettingsView.xaml.cs,
SharepointToolbox/Views/MainWindow.xaml
</files>
<action>
Create `Views/Tabs/` directory.
**SettingsView.xaml** — UserControl (embedded in TabItem):
```xml
<UserControl x:Class="SharepointToolbox.Views.Tabs.SettingsView">
<StackPanel Margin="16">
<!-- Language -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.language]}" />
<ComboBox Width="200" HorizontalAlignment="Left"
SelectedValue="{Binding SelectedLanguage}"
SelectedValuePath="Tag">
<ComboBoxItem Tag="en"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.lang.en]}" />
<ComboBoxItem Tag="fr"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.lang.fr]}" />
</ComboBox>
<Separator Margin="0,12" />
<!-- Data folder -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.folder]}" />
<DockPanel>
<Button DockPanel.Dock="Right"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.browse]}"
Command="{Binding BrowseFolderCommand}" Width="80" Margin="8,0,0,0" />
<TextBox Text="{Binding DataFolder, UpdateSourceTrigger=PropertyChanged}" />
</DockPanel>
</StackPanel>
</UserControl>
```
**SettingsView.xaml.cs**: Constructor receives `SettingsViewModel` via DI. Sets `DataContext = viewModel`. Calls `viewModel.LoadAsync()` in `Loaded` event to populate current settings.
Add `LoadAsync()` to SettingsViewModel if not present — loads current settings from SettingsService and sets `SelectedLanguage` and `DataFolder` properties.
**MainWindow.xaml** — Update Settings TabItem to use SettingsView (replace placeholder TextBlock):
```xml
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.settings]}">
<views:SettingsView />
</TabItem>
```
Add namespace: `xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs"`
Also register `SettingsView` in DI in App.xaml.cs (if not already):
```csharp
services.AddTransient<SettingsView>();
```
And resolve it in MainWindow constructor to inject into the Settings TabItem Content, OR use a DataTemplate approach. The simpler approach for Phase 1: resolve `SettingsView` from DI in `MainWindow.xaml.cs` constructor and set it as the TabItem Content directly:
```csharp
SettingsTabItem.Content = serviceProvider.GetRequiredService<SettingsView>();
```
Add `x:Name="SettingsTabItem"` to the Settings TabItem in XAML.
Run `dotnet build` and fix any 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. SettingsView.xaml contains language ComboBox with "en"/"fr" options and data folder TextBox with Browse button. MainWindow.xaml Settings tab shows SettingsView (not placeholder TextBlock).</done>
</task>
</tasks>
<verification>
- `dotnet build SharepointToolbox.sln` passes with 0 errors
- `dotnet test --filter "Category=Unit"` still passes (no regressions)
- ProfileManagementDialog has all three input fields using TranslationSource keys
- SettingsView language ComboBox has Tag="en" and Tag="fr" items
- MainWindow Settings TabItem Content is SettingsView (not placeholder)
</verification>
<success_criteria>
All Phase 1 UI is built. Application runs and shows: shell with 8 tabs, log panel, status bar, language switching, profile management dialog, and settings. Ready for the visual checkpoint in plan 01-08.
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation/01-07-SUMMARY.md`
</output>

View File

@@ -0,0 +1,161 @@
---
phase: 01-foundation
plan: 08
type: execute
wave: 6
depends_on:
- 01-07
files_modified: []
autonomous: false
requirements:
- FOUND-01
- FOUND-02
- FOUND-03
- FOUND-04
- FOUND-05
- FOUND-06
- FOUND-07
- FOUND-08
- FOUND-09
- FOUND-10
- FOUND-12
must_haves:
truths:
- "Application launches without crashing from dotnet run"
- "All 8 tabs visible with correct localized headers"
- "Language switch from Settings tab changes tab headers immediately without restart"
- "Profile management dialog opens, allows adding/renaming/deleting profiles"
- "Log panel at bottom shows timestamped messages with color coding"
- "Status bar shows tenant name and connection status"
- "All unit tests pass (zero failures)"
artifacts:
- path: "SharepointToolbox/App.xaml.cs"
provides: "Running application entry point"
- path: "SharepointToolbox/Views/MainWindow.xaml"
provides: "Visible shell with all required regions"
key_links:
- from: "Visual inspection"
to: "Phase 1 success criteria (ROADMAP.md)"
via: "Manual verification checklist"
pattern: "checkpoint"
---
<objective>
Run the full test suite, launch the application, and perform visual/functional verification of all Phase 1 success criteria before marking the phase complete.
Purpose: Automated tests validate logic, but WPF UI can fail visually in ways tests cannot catch (layout wrong, bindings silently failing, log panel invisible, crash on startup).
Output: Confirmed working foundation. Green light for Phase 2.
</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/ROADMAP.md
@.planning/phases/01-foundation/01-CONTEXT.md
@.planning/phases/01-foundation/01-07-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Run full test suite and verify zero failures</name>
<files></files>
<action>
Run the complete test suite:
```
dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --no-build -v normal
```
Expected result: All Unit and Integration tests pass. The following tests may remain as `Skip`:
- `SessionManagerTests.GetOrCreateContextAsync_CreatesContext` (requires interactive MSAL)
If any tests fail:
1. Read the failure message carefully
2. Fix the underlying code (do NOT delete or skip a failing test)
3. Re-run until all non-interactive tests pass
Also run a build to confirm zero warnings (treat warnings as potential future failures):
```
dotnet build SharepointToolbox.sln -warnaserror
```
If warnings-as-errors produces failures from NuGet or generated code, switch back to `dotnet build SharepointToolbox.sln` and list remaining warnings in the SUMMARY.
</action>
<verify>
<automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj 2>&1 | tail -15</automated>
</verify>
<done>Test output shows 0 failed. All non-interactive tests pass. Build produces 0 errors.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Complete Phase 1 Foundation:
- WPF shell with 8-tab layout, log panel (150px, black background, green text), StatusBar
- Toolbar: tenant ComboBox (220px), Connect, Manage Profiles, separator, Clear Session
- Profile management dialog (modal) — add, rename, delete tenant profiles
- Settings tab: language switcher (EN/FR) + data folder picker
- Dynamic language switching — changes tab headers without restart
- Serilog rolling file log + LogPanelSink writing to in-app RichTextBox
- Global exception handlers wired
- All infrastructure patterns in place (pagination helper, retry helper, FeatureViewModelBase)
</what-built>
<how-to-verify>
Run the application: `dotnet run --project SharepointToolbox/SharepointToolbox.csproj`
Check each item:
1. **Shell layout**: Window shows toolbar at top, TabControl in center with 8 tabs (Permissions, Storage, File Search, Duplicates, Templates, Bulk Operations, Folder Structure, Settings), log panel at bottom (black, 150px), status bar below log.
2. **Tab headers**: All 8 tabs have their English localized text (not "[tab.xxx]" — which would mean missing resx key).
3. **Language switch**:
- Open Settings tab
- Change language to French
- Verify tab headers change immediately (no restart)
- Change back to English to reset
4. **Profile management**:
- Click "Manage Profiles..."
- Modal dialog appears
- Add a test profile: Name="Test", URL="https://test.sharepoint.com", ClientId="test-id"
- Profile appears in the toolbar ComboBox after dialog closes
- Rename the profile in the dialog — new name shows in ComboBox
- Delete the profile — removed from ComboBox
5. **Log panel**:
- Verify log entries appear (at least startup messages) in `HH:mm:ss [XXXX] message` format
- Verify green color for info entries
6. **Data folder**:
- Open Settings tab
- Click Browse, select a folder
- Verify folder path appears in the TextBox
7. **Error handler** (optional — skip if risky):
- Confirm `%AppData%\SharepointToolbox\logs\` directory exists and contains today's log file
Report any visual issues, missing strings, or crashes.
</how-to-verify>
<resume-signal>Type "approved" if all checks pass, or describe specific issues found</resume-signal>
</task>
</tasks>
<verification>
All Phase 1 ROADMAP success criteria met:
1. User can create, rename, delete, and switch between tenant profiles via the UI
2. MSAL token cache infrastructure ready (interactive login requires a real Azure AD tenant — not testable in this checkpoint)
3. Per-tab progress bar + cancel button infrastructure built (no long-running ops in Phase 1 to demo, but FeatureViewModelBase tests prove the pattern)
4. Log panel surfaces errors in red; global exception handlers registered
5. Language switches between EN and FR dynamically without restart
</verification>
<success_criteria>
Human approves visual checkpoint. All unit tests green. Phase 1 complete — ready to begin Phase 2 (Permissions).
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation/01-08-SUMMARY.md`
</output>