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