From ff5ac94ae255f8758c25ff69f1c976a09f198dda Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 2 Apr 2026 11:38:35 +0200 Subject: [PATCH] docs(01-foundation): create phase plan (8 plans, 6 waves) Co-Authored-By: Claude Sonnet 4.6 --- .planning/ROADMAP.md | 14 +- .planning/phases/01-foundation/01-01-PLAN.md | 226 +++++++++++ .planning/phases/01-foundation/01-02-PLAN.md | 341 ++++++++++++++++ .planning/phases/01-foundation/01-03-PLAN.md | 254 ++++++++++++ .planning/phases/01-foundation/01-04-PLAN.md | 266 +++++++++++++ .planning/phases/01-foundation/01-05-PLAN.md | 253 ++++++++++++ .planning/phases/01-foundation/01-06-PLAN.md | 393 +++++++++++++++++++ .planning/phases/01-foundation/01-07-PLAN.md | 271 +++++++++++++ .planning/phases/01-foundation/01-08-PLAN.md | 161 ++++++++ 9 files changed, 2177 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/01-foundation/01-01-PLAN.md create mode 100644 .planning/phases/01-foundation/01-02-PLAN.md create mode 100644 .planning/phases/01-foundation/01-03-PLAN.md create mode 100644 .planning/phases/01-foundation/01-04-PLAN.md create mode 100644 .planning/phases/01-foundation/01-05-PLAN.md create mode 100644 .planning/phases/01-foundation/01-06-PLAN.md create mode 100644 .planning/phases/01-foundation/01-07-PLAN.md create mode 100644 .planning/phases/01-foundation/01-08-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 56cb28f..fe46fea 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 | - | diff --git a/.planning/phases/01-foundation/01-01-PLAN.md b/.planning/phases/01-foundation/01-01-PLAN.md new file mode 100644 index 0000000..3a97e4b --- /dev/null +++ b/.planning/phases/01-foundation/01-01-PLAN.md @@ -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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/01-foundation/01-CONTEXT.md +@.planning/phases/01-foundation/01-RESEARCH.md + + + + + + Task 1: Create solution and WPF project with all NuGet packages + + SharepointToolbox.sln, + SharepointToolbox/SharepointToolbox.csproj, + SharepointToolbox/App.xaml, + SharepointToolbox/App.xaml.cs, + SharepointToolbox/MainWindow.xaml, + SharepointToolbox/MainWindow.xaml.cs + + + 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 `net10.0-windows` + - Add `enable`, `enable` + - Add `false` (critical — PnP.Framework + MSAL use reflection) + - Add `SharepointToolbox.App` + - 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 `` and `` 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(); + app.MainWindow.Visibility = Visibility.Visible; + app.Run(); + } + + private static void RegisterServices(HostBuilderContext ctx, IServiceCollection services) + { + // Placeholder — services registered in subsequent plans + services.AddSingleton(); + } + } + ``` + + 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. + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-incremental 2>&1 | tail -5 + + Build output shows "Build succeeded" with 0 errors. App.xaml has no StartupUri. csproj contains PublishTrimmed=false and StartupObject. + + + + Task 2: Create xUnit test project with stub test 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 + + + 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 `enable`, `enable` + + 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). + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj 2>&1 | tail -10 + + dotnet test shows 0 failed, 7 skipped (or similar). All stub test files exist in correct subdirectories. + + + + + +- `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 `false` +- SharepointToolbox.csproj contains `SharepointToolbox.App` +- App.xaml.cs Main method is decorated with `[STAThread]` + + + +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). + + + +After completion, create `.planning/phases/01-foundation/01-01-SUMMARY.md` + diff --git a/.planning/phases/01-foundation/01-02-PLAN.md b/.planning/phases/01-foundation/01-02-PLAN.md new file mode 100644 index 0000000..9250850 --- /dev/null +++ b/.planning/phases/01-foundation/01-02-PLAN.md @@ -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 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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + + + Task 1: Core models and WeakReferenceMessenger messages + + SharepointToolbox/Core/Models/TenantProfile.cs, + SharepointToolbox/Core/Models/OperationProgress.cs, + SharepointToolbox/Core/Messages/TenantSwitchedMessage.cs, + SharepointToolbox/Core/Messages/LanguageChangedMessage.cs + + + 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 + { + public TenantSwitchedMessage(TenantProfile profile) : base(profile) { } + } + ``` + + **LanguageChangedMessage.cs** + ```csharp + using CommunityToolkit.Mvvm.Messaging.Messages; + + namespace SharepointToolbox.Core.Messages; + + public sealed class LanguageChangedMessage : ValueChangedMessage + { + public LanguageChangedMessage(string cultureCode) : base(cultureCode) { } + } + ``` + + Run `dotnet build SharepointToolbox/SharepointToolbox.csproj` to verify no errors. + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj 2>&1 | tail -5 + + Build succeeds. Four files created. TenantProfile fields match JSON schema. OperationProgress is a record with Indeterminate factory. + + + + Task 2: SharePointPaginationHelper, ExecuteQueryRetryHelper, LogPanelSink + + SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs, + SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs, + SharepointToolbox/Infrastructure/Logging/LogPanelSink.cs + + + 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 + { + /// + /// Enumerates all items in a SharePoint list, bypassing the 5,000-item threshold. + /// Uses CamlQuery with RowLimit=2000 and ListItemCollectionPosition for pagination. + /// Never call ExecuteQuery directly on a list — always use this helper. + /// + public static async IAsyncEnumerable GetAllItemsAsync( + ClientContext ctx, + List list, + CamlQuery? baseQuery = null, + CancellationToken ct = default) + { + var query = baseQuery ?? CamlQuery.CreateAllItemsQuery(); + query.ViewXml = BuildPagedViewXml(query.ViewXml, rowLimit: 2000); + query.ListItemCollectionPosition = null; + + do + { + ct.ThrowIfCancellationRequested(); + var items = list.GetItems(query); + ctx.Load(items); + await ctx.ExecuteQueryAsync(); + + foreach (var item in items) + yield return item; + + query.ListItemCollectionPosition = items.ListItemCollectionPosition; + } + while (query.ListItemCollectionPosition != null); + } + + private static string BuildPagedViewXml(string? existingXml, int rowLimit) + { + // Inject or replace RowLimit in existing CAML, or create minimal view + if (string.IsNullOrWhiteSpace(existingXml)) + return $"{rowLimit}"; + + // Simple replacement approach — adequate for Phase 1 + if (existingXml.Contains("", StringComparison.OrdinalIgnoreCase)) + { + return System.Text.RegularExpressions.Regex.Replace( + existingXml, @"]*>\d+", + $"{rowLimit}", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + return existingXml.Replace("", $"{rowLimit}", + StringComparison.OrdinalIgnoreCase); + } + } + ``` + + **ExecuteQueryRetryHelper.cs** + ```csharp + using Microsoft.SharePoint.Client; + using SharepointToolbox.Core.Models; + + namespace SharepointToolbox.Core.Helpers; + + public static class ExecuteQueryRetryHelper + { + private const int MaxRetries = 5; + + /// + /// Executes a SharePoint query with automatic retry on throttle (429/503). + /// Surfaces retry events via progress for user visibility ("Throttled — retrying in 30s…"). + /// + public static async Task ExecuteQueryRetryAsync( + ClientContext ctx, + IProgress? progress = null, + CancellationToken ct = default) + { + int attempt = 0; + while (true) + { + ct.ThrowIfCancellationRequested(); + try + { + await ctx.ExecuteQueryAsync(); + return; + } + catch (Exception ex) when (IsThrottleException(ex) && attempt < MaxRetries) + { + attempt++; + int delaySeconds = (int)Math.Pow(2, attempt) * 5; // 10, 20, 40, 80, 160s + progress?.Report(OperationProgress.Indeterminate( + $"Throttled by SharePoint — retrying in {delaySeconds}s (attempt {attempt}/{MaxRetries})…")); + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct); + } + } + } + + private static bool IsThrottleException(Exception ex) + { + var msg = ex.Message; + return msg.Contains("429") || msg.Contains("503") || + msg.Contains("throttl", StringComparison.OrdinalIgnoreCase); + } + } + ``` + + **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; + + /// + /// Custom Serilog sink that writes timestamped, color-coded entries to a WPF RichTextBox. + /// Format: HH:mm:ss [LEVEL] Message — green=info/success, orange=warning, red=error. + /// All writes dispatch to the UI thread via Application.Current.Dispatcher. + /// + public class LogPanelSink : ILogEventSink + { + private readonly RichTextBox _richTextBox; + + public LogPanelSink(RichTextBox richTextBox) + { + _richTextBox = richTextBox; + } + + public void Emit(LogEvent logEvent) + { + var message = logEvent.RenderMessage(); + var timestamp = logEvent.Timestamp.ToString("HH:mm:ss"); + var level = logEvent.Level.ToString().ToUpperInvariant()[..4]; // INFO, WARN, ERRO, FATL + var text = $"{timestamp} [{level}] {message}"; + var color = GetColor(logEvent.Level); + + Application.Current?.Dispatcher.InvokeAsync(() => + { + var para = new Paragraph(new Run(text) { Foreground = new SolidColorBrush(color) }) + { + Margin = new Thickness(0) + }; + _richTextBox.Document.Blocks.Add(para); + _richTextBox.ScrollToEnd(); + }); + } + + private static Color GetColor(LogEventLevel level) => level switch + { + LogEventLevel.Warning => Colors.Orange, + LogEventLevel.Error or LogEventLevel.Fatal => Colors.Red, + _ => Colors.LimeGreen + }; + } + ``` + + Run `dotnet build SharepointToolbox/SharepointToolbox.csproj` to verify no errors. + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj 2>&1 | tail -5 + + Build succeeds. SharePointPaginationHelper uses ListItemCollectionPosition. ExecuteQueryRetryHelper detects throttle exceptions. LogPanelSink dispatches to UI thread via Dispatcher.InvokeAsync. + + + + + +- `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` + + + +All 7 Core/Infrastructure files created and compiling. Models match JSON schema field names. Pagination helper correctly loops until ListItemCollectionPosition is null. + + + +After completion, create `.planning/phases/01-foundation/01-02-SUMMARY.md` + diff --git a/.planning/phases/01-foundation/01-03-PLAN.md b/.planning/phases/01-foundation/01-03-PLAN.md new file mode 100644 index 0000000..551f645 --- /dev/null +++ b/.planning/phases/01-foundation/01-03-PLAN.md @@ -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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +```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; +} +``` + + +// Sharepoint_Export_profiles.json +{ "profiles": [{ "name": "...", "tenantUrl": "...", "clientId": "..." }] } + +// Sharepoint_Settings.json +{ "dataFolder": "...", "lang": "en" } + + + + + + + Task 1: ProfileRepository and ProfileService with write-then-replace + + SharepointToolbox/Infrastructure/Persistence/ProfileRepository.cs, + SharepointToolbox/Services/ProfileService.cs, + SharepointToolbox.Tests/Services/ProfileServiceTests.cs + + + - 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) + + + 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> LoadAsync() + { + if (!File.Exists(_filePath)) + return Array.Empty(); + + var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); + var root = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return root?.Profiles ?? Array.Empty(); + } + + public async Task SaveAsync(IReadOnlyList 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 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> 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")]`. + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~ProfileServiceTests" 2>&1 | tail -10 + + All ProfileServiceTests pass (no skips). JSON output uses camelCase field names matching existing schema. Write-then-replace with SemaphoreSlim implemented. + + + + Task 2: SettingsRepository and SettingsService + + SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs, + SharepointToolbox/Services/SettingsService.cs, + SharepointToolbox.Tests/Services/SettingsServiceTests.cs + + + - 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 + + + **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 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 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")]`. + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SettingsServiceTests" 2>&1 | tail -10 + + All SettingsServiceTests pass. AppSettings serializes to dataFolder/lang (camelCase). Default settings returned when file is absent. + + + + + +- `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`) + + + +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. + + + +After completion, create `.planning/phases/01-foundation/01-03-SUMMARY.md` + diff --git a/.planning/phases/01-foundation/01-04-PLAN.md b/.planning/phases/01-foundation/01-04-PLAN.md new file mode 100644 index 0000000..d6e6cb1 --- /dev/null +++ b/.planning/phases/01-foundation/01-04-PLAN.md @@ -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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +```csharp +public class TenantProfile +{ + public string Name { get; set; } + public string TenantUrl { get; set; } + public string ClientId { get; set; } +} +``` + + + + + + + Task 1: MsalClientFactory — per-ClientId PCA with MsalCacheHelper + + SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs, + SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs + + + - 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) + + + Create `Infrastructure/Auth/` directory. + + **MsalClientFactory.cs** — implement exactly as per research Pattern 3: + ```csharp + namespace SharepointToolbox.Infrastructure.Auth; + + public class MsalClientFactory + { + private readonly Dictionary _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 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. + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~MsalClientFactoryTests" 2>&1 | tail -10 + + MsalClientFactoryTests pass. Different clientIds produce different instances. Same clientId produces same instance on second call. + + + + Task 2: SessionManager — singleton ClientContext holder + + SharepointToolbox/Services/SessionManager.cs, + SharepointToolbox.Tests/Auth/SessionManagerTests.cs + + + - 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 + + + **SessionManager.cs** — singleton, owns all ClientContext instances: + ```csharp + namespace SharepointToolbox.Services; + + public class SessionManager + { + private readonly MsalClientFactory _msalFactory; + private readonly Dictionary _contexts = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + public SessionManager(MsalClientFactory msalFactory) + { + _msalFactory = msalFactory; + } + + public bool IsAuthenticated(string tenantUrl) => + _contexts.ContainsKey(NormalizeUrl(tenantUrl)); + + /// + /// Returns existing ClientContext or creates a new one via interactive MSAL login. + /// Only SessionManager holds ClientContext instances — never return to callers for storage. + /// + public async Task 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(); } + } + + /// + /// Clears MSAL accounts and disposes the ClientContext for the given tenant. + /// Called by "Clear Session" button and on tenant profile deletion. + /// + 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")]`. + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SessionManagerTests" 2>&1 | tail -10 + + SessionManagerTests pass (interactive login test skipped). IsAuthenticated, ClearSessionAsync, and argument validation tests are green. SessionManager is the only holder of ClientContext. + + + + + +- `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 + + + +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. + + + +After completion, create `.planning/phases/01-foundation/01-04-SUMMARY.md` + diff --git a/.planning/phases/01-foundation/01-05-PLAN.md b/.planning/phases/01-foundation/01-05-PLAN.md new file mode 100644 index 0000000..f53c59e --- /dev/null +++ b/.planning/phases/01-foundation/01-05-PLAN.md @@ -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 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" +--- + + +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 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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +```csharp +public sealed class LanguageChangedMessage : ValueChangedMessage +{ + public LanguageChangedMessage(string cultureCode) : base(cultureCode) { } +} +``` + + + + + + + Task 1: TranslationSource singleton + EN/FR resx files + + SharepointToolbox/Localization/TranslationSource.cs, + SharepointToolbox/Localization/Strings.resx, + SharepointToolbox/Localization/Strings.fr.resx, + SharepointToolbox.Tests/Localization/TranslationSourceTests.cs + + + - 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) + + + 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 `` 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. + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~TranslationSourceTests" 2>&1 | tail -10 + + TranslationSourceTests pass. Missing key returns "[key]". Culture switch fires PropertyChanged with empty property name. Strings.resx contains all required keys. + + + + Task 2: Serilog integration tests and App.xaml.cs logging wiring verification + + SharepointToolbox.Tests/Integration/LoggingIntegrationTests.cs + + + 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) + ``` + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~LoggingIntegrationTests" 2>&1 | tail -10 + + LoggingIntegrationTests pass. Log file created in temp directory with expected content. LogPanelSink type check passes. + + + + + +- `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 + + + +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. + + + +After completion, create `.planning/phases/01-foundation/01-05-SUMMARY.md` + diff --git a/.planning/phases/01-foundation/01-06-PLAN.md b/.planning/phases/01-foundation/01-06-PLAN.md new file mode 100644 index 0000000..0bb47fc --- /dev/null +++ b/.planning/phases/01-foundation/01-06-PLAN.md @@ -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, 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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + +```csharp +public class TenantProfile { string Name; string TenantUrl; string ClientId; } +public record OperationProgress(int Current, int Total, string Message) +``` + + +```csharp +public sealed class TenantSwitchedMessage : ValueChangedMessage +public sealed class LanguageChangedMessage : ValueChangedMessage +``` + + +```csharp +// ProfileService: GetProfilesAsync(), AddProfileAsync(), RenameProfileAsync(), DeleteProfileAsync() +// SessionManager: IsAuthenticated(url), GetOrCreateContextAsync(profile, ct), ClearSessionAsync(url) +// SettingsService: GetSettingsAsync(), SetLanguageAsync(code), SetDataFolderAsync(path) +``` + + +```csharp +// TranslationSource.Instance[key] — binding: Source={x:Static loc:TranslationSource.Instance}, Path=[key] +``` + + +// 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 % + + + + + + + Task 1: FeatureViewModelBase + unit tests + + SharepointToolbox/ViewModels/FeatureViewModelBase.cs, + SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs + + + - Test: IsRunning is true while operation executes, false after completion + - Test: ProgressValue and StatusMessage update via IProgress 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) + + + 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 _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 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(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 progress); + + protected override void OnActivated() + { + Messenger.Register(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 + { + public ProgressUpdatedMessage(OperationProgress progress) : base(progress) { } + } + ``` + + **FeatureViewModelBaseTests.cs** — Replace stub. Use a concrete test subclass: + ```csharp + private class TestViewModel : FeatureViewModelBase + { + public TestViewModel(ILogger logger) : base(logger) { } + public Func, Task>? OperationFunc { get; set; } + protected override Task RunOperationAsync(CancellationToken ct, IProgress p) + => OperationFunc?.Invoke(ct, p) ?? Task.CompletedTask; + } + ``` + All tests in `[Trait("Category", "Unit")]`. + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~FeatureViewModelBaseTests" 2>&1 | tail -10 + + All FeatureViewModelBaseTests pass. IsRunning lifecycle correct. Cancellation handled gracefully. Exception caught with error message set. + + + + Task 2: MainWindowViewModel, shell ViewModels, and MainWindow XAML + + 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 + + + Create `ViewModels/Tabs/` and `Views/` directories. + + **MainWindowViewModel.cs**: + ```csharp + [ObservableProperty] private TenantProfile? _selectedProfile; + [ObservableProperty] private string _connectionStatus = "Not connected"; + public ObservableCollection 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 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 + + + + + +