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
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
**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
**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 |
|-------|----------------|--------|-----------|
| 1. Foundation | 0/? | Not started | - |
| 1. Foundation | 0/8 | Planning done | - |
| 2. Permissions | 0/? | Not started | - |
| 3. Storage and File Operations | 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>