docs(11): create phase plan for HTML export branding and ViewModel integration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,9 +50,9 @@
|
||||
4. `GraphUserDirectoryService.GetUsersAsync` returns all enabled member users for a tenant, following `@odata.nextLink` until exhausted, without truncating at 999
|
||||
**Plans**: 3 plans
|
||||
Plans:
|
||||
- [ ] 10-01-PLAN.md — Logo models, BrandingRepository, BrandingService with validation/compression
|
||||
- [ ] 10-02-PLAN.md — GraphUserDirectoryService with PageIterator pagination
|
||||
- [ ] 10-03-PLAN.md — DI registration in App.xaml.cs and full test suite gate
|
||||
- [x] 10-01-PLAN.md — Logo models, BrandingRepository, BrandingService with validation/compression
|
||||
- [x] 10-02-PLAN.md — GraphUserDirectoryService with PageIterator pagination
|
||||
- [x] 10-03-PLAN.md — DI registration in App.xaml.cs and full test suite gate
|
||||
|
||||
### Phase 11: HTML Export Branding + ViewModel Integration
|
||||
**Goal**: All five HTML reports display MSP and client logos in a consistent header, and administrators can manage logos from Settings and the profile dialog without touching the View layer.
|
||||
@@ -64,7 +64,12 @@ Plans:
|
||||
3. When no logo is configured, the HTML export header contains no broken image placeholder and the report renders identically to the pre-branding output
|
||||
4. SettingsViewModel exposes browse/clear commands for MSP logo; ProfileManagementViewModel exposes browse/clear commands for client logo — both commands are exercisable without opening any View
|
||||
5. Auto-pulling the client logo from the tenant's Entra branding API stores the logo in the tenant profile and falls back silently when no Entra branding is configured
|
||||
**Plans**: TBD
|
||||
**Plans**: 4 plans
|
||||
Plans:
|
||||
- [ ] 11-01-PLAN.md — ReportBranding model + BrandingHtmlHelper static class with unit tests
|
||||
- [ ] 11-02-PLAN.md — Add optional branding param to all 5 HTML export services
|
||||
- [ ] 11-03-PLAN.md — Wire IBrandingService into all 5 export ViewModels
|
||||
- [ ] 11-04-PLAN.md — Logo management commands (Settings + Profile) and Entra auto-pull
|
||||
|
||||
### Phase 12: Branding UI Views
|
||||
**Goal**: Administrators can see, import, preview, and clear logos directly in the Settings and profile management dialogs.
|
||||
@@ -105,8 +110,8 @@ Plans:
|
||||
|-------|-----------|-------|--------|-----------|
|
||||
| 1-5 | v1.0 | 36/36 | Shipped | 2026-04-07 |
|
||||
| 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 |
|
||||
| 10. Branding Data Foundation | 3/3 | Complete | 2026-04-08 | — |
|
||||
| 11. HTML Export Branding + ViewModel Integration | v2.2 | 0/? | Not started | — |
|
||||
| 10. Branding Data Foundation | v2.2 | 3/3 | Complete | 2026-04-08 |
|
||||
| 11. HTML Export Branding + ViewModel Integration | v2.2 | 0/4 | Planning complete | — |
|
||||
| 12. Branding UI Views | v2.2 | 0/? | Not started | — |
|
||||
| 13. User Directory ViewModel | v2.2 | 0/? | Not started | — |
|
||||
| 14. User Directory View | v2.2 | 0/? | Not started | — |
|
||||
|
||||
209
.planning/phases/11-html-export-branding/11-01-PLAN.md
Normal file
209
.planning/phases/11-html-export-branding/11-01-PLAN.md
Normal file
@@ -0,0 +1,209 @@
|
||||
---
|
||||
phase: 11-html-export-branding
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- SharepointToolbox/Core/Models/ReportBranding.cs
|
||||
- SharepointToolbox/Services/Export/BrandingHtmlHelper.cs
|
||||
- SharepointToolbox.Tests/Services/Export/BrandingHtmlHelperTests.cs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BRAND-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "BrandingHtmlHelper.BuildBrandingHeader returns a div with two img tags when both MSP and client logos are provided"
|
||||
- "BrandingHtmlHelper.BuildBrandingHeader returns a div with one img tag when only MSP or only client logo is provided"
|
||||
- "BrandingHtmlHelper.BuildBrandingHeader returns empty string when branding is null or both logos are null"
|
||||
- "ReportBranding record bundles MspLogo and ClientLogo as nullable LogoData properties"
|
||||
artifacts:
|
||||
- path: "SharepointToolbox/Core/Models/ReportBranding.cs"
|
||||
provides: "Immutable DTO bundling MSP and client logos for export pipeline"
|
||||
contains: "record ReportBranding"
|
||||
- path: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs"
|
||||
provides: "Static helper generating branding header HTML fragment"
|
||||
contains: "BuildBrandingHeader"
|
||||
- path: "SharepointToolbox.Tests/Services/Export/BrandingHtmlHelperTests.cs"
|
||||
provides: "Unit tests covering all 4 branding states"
|
||||
min_lines: 50
|
||||
key_links:
|
||||
- from: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs"
|
||||
to: "SharepointToolbox/Core/Models/ReportBranding.cs"
|
||||
via: "parameter type"
|
||||
pattern: "ReportBranding\\?"
|
||||
- from: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs"
|
||||
to: "SharepointToolbox/Core/Models/LogoData.cs"
|
||||
via: "property access"
|
||||
pattern: "MimeType.*Base64"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the ReportBranding model and BrandingHtmlHelper static class that all HTML exporters will call to render the branding header.
|
||||
|
||||
Purpose: Centralizes the branding header HTML generation so all 5 exporters share identical markup. This is the foundation artifact that Plan 02 depends on.
|
||||
|
||||
Output: ReportBranding record, BrandingHtmlHelper static class, and comprehensive unit tests covering all logo combination states.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/11-html-export-branding/11-CONTEXT.md
|
||||
@.planning/phases/11-html-export-branding/11-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Phase 10 infrastructure this plan depends on -->
|
||||
|
||||
From SharepointToolbox/Core/Models/LogoData.cs:
|
||||
```csharp
|
||||
namespace SharepointToolbox.Core.Models;
|
||||
|
||||
public record LogoData
|
||||
{
|
||||
public string Base64 { get; init; } = string.Empty;
|
||||
public string MimeType { get; init; } = string.Empty;
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/Core/Models/BrandingSettings.cs:
|
||||
```csharp
|
||||
namespace SharepointToolbox.Core.Models;
|
||||
|
||||
public class BrandingSettings
|
||||
{
|
||||
public LogoData? MspLogo { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/Core/Models/TenantProfile.cs (relevant property):
|
||||
```csharp
|
||||
public LogoData? ClientLogo { get; set; }
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create ReportBranding record and BrandingHtmlHelper with tests</name>
|
||||
<files>
|
||||
SharepointToolbox/Core/Models/ReportBranding.cs,
|
||||
SharepointToolbox/Services/Export/BrandingHtmlHelper.cs,
|
||||
SharepointToolbox.Tests/Services/Export/BrandingHtmlHelperTests.cs
|
||||
</files>
|
||||
<behavior>
|
||||
- Test 1: BuildBrandingHeader with null ReportBranding returns empty string
|
||||
- Test 2: BuildBrandingHeader with both logos null returns empty string
|
||||
- Test 3: BuildBrandingHeader with only MspLogo returns HTML with one img tag containing MSP base64 data-URI, no second img
|
||||
- Test 4: BuildBrandingHeader with only ClientLogo returns HTML with one img tag containing client base64 data-URI, no flex spacer div
|
||||
- Test 5: BuildBrandingHeader with both logos returns HTML with two img tags and a flex spacer div between them
|
||||
- Test 6: All generated img tags use inline data-URI format: src="data:{MimeType};base64,{Base64}"
|
||||
- Test 7: All generated img tags have max-height:60px and max-width:200px styles
|
||||
- Test 8: The outer div uses display:flex;gap:16px;align-items:center styling
|
||||
</behavior>
|
||||
<action>
|
||||
1. Create `SharepointToolbox/Core/Models/ReportBranding.cs`:
|
||||
```csharp
|
||||
namespace SharepointToolbox.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Bundles MSP and client logos for passing to export services.
|
||||
/// Export services receive this as a simple DTO — they don't know
|
||||
/// about IBrandingService or ProfileService.
|
||||
/// </summary>
|
||||
public record ReportBranding(LogoData? MspLogo, LogoData? ClientLogo);
|
||||
```
|
||||
This is a positional record (OK because it is never deserialized from JSON — it is always constructed in code).
|
||||
|
||||
2. Create `SharepointToolbox/Services/Export/BrandingHtmlHelper.cs`:
|
||||
```csharp
|
||||
using System.Text;
|
||||
using SharepointToolbox.Core.Models;
|
||||
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Generates the branding header HTML fragment for HTML reports.
|
||||
/// Called by each HTML export service between <body> and <h1>.
|
||||
/// Returns empty string when no logos are configured (no broken images).
|
||||
/// </summary>
|
||||
internal static class BrandingHtmlHelper
|
||||
{
|
||||
public static string BuildBrandingHeader(ReportBranding? branding)
|
||||
{
|
||||
if (branding is null) return string.Empty;
|
||||
|
||||
var msp = branding.MspLogo;
|
||||
var client = branding.ClientLogo;
|
||||
|
||||
if (msp is null && client is null) return string.Empty;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<div style=\"display:flex;gap:16px;align-items:center;padding:12px 24px 0;\">");
|
||||
|
||||
if (msp is not null)
|
||||
sb.AppendLine($" <img src=\"data:{msp.MimeType};base64,{msp.Base64}\" alt=\"\" style=\"max-height:60px;max-width:200px;object-fit:contain;\">");
|
||||
|
||||
if (msp is not null && client is not null)
|
||||
sb.AppendLine(" <div style=\"flex:1\"></div>");
|
||||
|
||||
if (client is not null)
|
||||
sb.AppendLine($" <img src=\"data:{client.MimeType};base64,{client.Base64}\" alt=\"\" style=\"max-height:60px;max-width:200px;object-fit:contain;\">");
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
```
|
||||
Key decisions per CONTEXT.md locked decisions:
|
||||
- `display:flex;gap:16px` layout (MSP left, client right)
|
||||
- `<img src="data:{MimeType};base64,{Base64}">` inline data-URI format
|
||||
- `max-height:60px` keeps logos reasonable
|
||||
- Returns empty string (not null) when no branding — callers need no null checks
|
||||
- `alt=""` (decorative image — not essential content)
|
||||
- Class is `internal` — only used within Services.Export namespace. Tests access via `InternalsVisibleTo`.
|
||||
|
||||
3. Create `SharepointToolbox.Tests/Services/Export/BrandingHtmlHelperTests.cs`:
|
||||
Write tests FIRST (RED phase), then verify the implementation makes them GREEN.
|
||||
Use `[Trait("Category", "Unit")]` per project convention.
|
||||
Create helper method `MakeLogo(string mime = "image/png", string base64 = "dGVzdA==")` to build test LogoData instances.
|
||||
Tests must assert exact HTML structure: data-URI format, style attributes, flex spacer presence/absence.
|
||||
|
||||
4. Verify the test project has `InternalsVisibleTo` for the helper class. Check `SharepointToolbox.csproj` or `AssemblyInfo.cs` for `[assembly: InternalsVisibleTo("SharepointToolbox.Tests")]`. If missing, add `<InternalsVisibleTo Include="SharepointToolbox.Tests" />` inside an `<ItemGroup>` in `SharepointToolbox.csproj`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~BrandingHtmlHelperTests" --no-build -q</automated>
|
||||
</verify>
|
||||
<done>ReportBranding record exists in Core/Models. BrandingHtmlHelper generates correct HTML for all 4 states (null branding, both null, single logo, both logos). All tests pass. Build succeeds with no warnings.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
dotnet build --no-restore -warnaserror
|
||||
dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~BrandingHtmlHelper" --no-build -q
|
||||
```
|
||||
Both commands must pass with zero failures.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- ReportBranding record exists at Core/Models/ReportBranding.cs as a positional record with two nullable LogoData params
|
||||
- BrandingHtmlHelper.BuildBrandingHeader handles all 4 states correctly (null branding, both null, single, both)
|
||||
- Generated HTML uses data-URI format, flex layout, 60px max-height per locked decisions
|
||||
- No broken image tags when logos are missing
|
||||
- Tests cover all states with assertions on HTML structure
|
||||
- Build passes with zero warnings
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/11-html-export-branding/11-01-SUMMARY.md`
|
||||
</output>
|
||||
308
.planning/phases/11-html-export-branding/11-02-PLAN.md
Normal file
308
.planning/phases/11-html-export-branding/11-02-PLAN.md
Normal file
@@ -0,0 +1,308 @@
|
||||
---
|
||||
phase: 11-html-export-branding
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["11-01"]
|
||||
files_modified:
|
||||
- SharepointToolbox/Services/Export/HtmlExportService.cs
|
||||
- SharepointToolbox/Services/Export/SearchHtmlExportService.cs
|
||||
- SharepointToolbox/Services/Export/StorageHtmlExportService.cs
|
||||
- SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs
|
||||
- SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs
|
||||
- SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs
|
||||
- SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs
|
||||
- SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs
|
||||
- SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs
|
||||
- SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BRAND-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Each of the 5 HTML exporters accepts an optional ReportBranding? branding = null parameter on BuildHtml and WriteAsync"
|
||||
- "When branding is provided with logos, the exported HTML contains the branding header div between body and h1"
|
||||
- "When branding is null or has no logos, the exported HTML is identical to pre-branding output"
|
||||
- "Existing callers without branding parameter still compile and produce identical output"
|
||||
artifacts:
|
||||
- path: "SharepointToolbox/Services/Export/HtmlExportService.cs"
|
||||
provides: "Permissions HTML export with optional branding"
|
||||
contains: "ReportBranding? branding = null"
|
||||
- path: "SharepointToolbox/Services/Export/SearchHtmlExportService.cs"
|
||||
provides: "Search HTML export with optional branding"
|
||||
contains: "ReportBranding? branding = null"
|
||||
- path: "SharepointToolbox/Services/Export/StorageHtmlExportService.cs"
|
||||
provides: "Storage HTML export with optional branding"
|
||||
contains: "ReportBranding? branding = null"
|
||||
- path: "SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs"
|
||||
provides: "Duplicates HTML export with optional branding"
|
||||
contains: "ReportBranding? branding = null"
|
||||
- path: "SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs"
|
||||
provides: "User access HTML export with optional branding"
|
||||
contains: "ReportBranding? branding = null"
|
||||
key_links:
|
||||
- from: "SharepointToolbox/Services/Export/HtmlExportService.cs"
|
||||
to: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs"
|
||||
via: "static method call"
|
||||
pattern: "BrandingHtmlHelper\\.BuildBrandingHeader"
|
||||
- from: "SharepointToolbox/Services/Export/SearchHtmlExportService.cs"
|
||||
to: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs"
|
||||
via: "static method call"
|
||||
pattern: "BrandingHtmlHelper\\.BuildBrandingHeader"
|
||||
- from: "SharepointToolbox/Services/Export/StorageHtmlExportService.cs"
|
||||
to: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs"
|
||||
via: "static method call"
|
||||
pattern: "BrandingHtmlHelper\\.BuildBrandingHeader"
|
||||
- from: "SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs"
|
||||
to: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs"
|
||||
via: "static method call"
|
||||
pattern: "BrandingHtmlHelper\\.BuildBrandingHeader"
|
||||
- from: "SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs"
|
||||
to: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs"
|
||||
via: "static method call"
|
||||
pattern: "BrandingHtmlHelper\\.BuildBrandingHeader"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add optional `ReportBranding? branding = null` parameter to all 5 HTML export services and inject the branding header HTML between `<body>` and `<h1>` in each.
|
||||
|
||||
Purpose: BRAND-05 requires all five HTML report types to display logos. This plan modifies each exporter to call `BrandingHtmlHelper.BuildBrandingHeader(branding)` at the correct injection point. Default null parameter ensures zero regression for existing callers.
|
||||
|
||||
Output: All 5 HTML export services accept branding, inject header when provided, and extended tests verify branding appears in output.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/11-html-export-branding/11-CONTEXT.md
|
||||
@.planning/phases/11-html-export-branding/11-RESEARCH.md
|
||||
@.planning/phases/11-html-export-branding/11-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 11-01 (must be completed first) -->
|
||||
|
||||
From SharepointToolbox/Core/Models/ReportBranding.cs:
|
||||
```csharp
|
||||
namespace SharepointToolbox.Core.Models;
|
||||
public record ReportBranding(LogoData? MspLogo, LogoData? ClientLogo);
|
||||
```
|
||||
|
||||
From SharepointToolbox/Services/Export/BrandingHtmlHelper.cs:
|
||||
```csharp
|
||||
namespace SharepointToolbox.Services.Export;
|
||||
internal static class BrandingHtmlHelper
|
||||
{
|
||||
public static string BuildBrandingHeader(ReportBranding? branding);
|
||||
}
|
||||
```
|
||||
|
||||
<!-- Current WriteAsync signatures that need branding param added -->
|
||||
|
||||
HtmlExportService.cs:
|
||||
```csharp
|
||||
public string BuildHtml(IReadOnlyList<PermissionEntry> entries)
|
||||
public string BuildSimplifiedHtml(IReadOnlyList<SimplifiedPermissionEntry> entries)
|
||||
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct)
|
||||
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct)
|
||||
```
|
||||
|
||||
SearchHtmlExportService.cs:
|
||||
```csharp
|
||||
public string BuildHtml(IReadOnlyList<SearchResult> results)
|
||||
public async Task WriteAsync(IReadOnlyList<SearchResult> results, string filePath, CancellationToken ct)
|
||||
```
|
||||
|
||||
StorageHtmlExportService.cs:
|
||||
```csharp
|
||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes)
|
||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
|
||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
|
||||
```
|
||||
|
||||
DuplicatesHtmlExportService.cs:
|
||||
```csharp
|
||||
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups)
|
||||
public async Task WriteAsync(IReadOnlyList<DuplicateGroup> groups, string filePath, CancellationToken ct)
|
||||
```
|
||||
|
||||
UserAccessHtmlExportService.cs:
|
||||
```csharp
|
||||
public string BuildHtml(IReadOnlyList<UserAccessEntry> entries)
|
||||
public async Task WriteAsync(IReadOnlyList<UserAccessEntry> entries, string filePath, CancellationToken ct)
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add branding parameter to all 5 HTML export services</name>
|
||||
<files>
|
||||
SharepointToolbox/Services/Export/HtmlExportService.cs,
|
||||
SharepointToolbox/Services/Export/SearchHtmlExportService.cs,
|
||||
SharepointToolbox/Services/Export/StorageHtmlExportService.cs,
|
||||
SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs,
|
||||
SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs
|
||||
</files>
|
||||
<action>
|
||||
For each of the 5 HTML export service files, apply the same two-step modification:
|
||||
|
||||
**Step 1: Add `using SharepointToolbox.Core.Models;`** at the top if not already present (needed for `ReportBranding`).
|
||||
|
||||
**Step 2: Modify BuildHtml signatures.** Add `ReportBranding? branding = null` as the LAST parameter:
|
||||
|
||||
For `HtmlExportService.cs`:
|
||||
- `BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null)`
|
||||
- `BuildSimplifiedHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null)`
|
||||
- In both methods, find the line `sb.AppendLine("<body>");` followed by `sb.AppendLine("<h1>...")`
|
||||
- Insert `sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));` AFTER the `<body>` line and BEFORE the `<h1>` line
|
||||
|
||||
For `SearchHtmlExportService.cs`:
|
||||
- `BuildHtml(IReadOnlyList<SearchResult> results, ReportBranding? branding = null)`
|
||||
- This file uses raw string literal (`"""`). Find `<body>` followed by `<h1>File Search Results</h1>`.
|
||||
- Split the raw string: close the raw string after `<body>`, append the branding header call, then start a new raw string or `sb.AppendLine` for the `<h1>`. The simplest approach: break the raw string literal at the `<body>` / `<h1>` boundary and insert `sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));` between the two pieces.
|
||||
|
||||
For `StorageHtmlExportService.cs`:
|
||||
- Two `BuildHtml` overloads — add `ReportBranding? branding = null` to both
|
||||
- Both use raw string literals. Same injection approach: break at `<body>` / `<h1>` boundary.
|
||||
- The 2-param `BuildHtml` also needs the branding param: `BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)`
|
||||
|
||||
For `DuplicatesHtmlExportService.cs`:
|
||||
- `BuildHtml(IReadOnlyList<DuplicateGroup> groups, ReportBranding? branding = null)`
|
||||
- Raw string literal. Same injection approach.
|
||||
|
||||
For `UserAccessHtmlExportService.cs`:
|
||||
- `BuildHtml(IReadOnlyList<UserAccessEntry> entries, ReportBranding? branding = null)`
|
||||
- Uses `sb.AppendLine("<body>");` and `sb.AppendLine("<h1>User Access Audit Report</h1>");`
|
||||
- Insert `sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));` between them.
|
||||
|
||||
**Step 3: Modify WriteAsync signatures.** Add `ReportBranding? branding = null` as the LAST parameter on each WriteAsync overload. Inside each WriteAsync, pass `branding` through to the corresponding `BuildHtml` call.
|
||||
|
||||
Per RESEARCH Pitfall 2: Place `branding` AFTER `CancellationToken ct` in WriteAsync signatures so existing positional callers are unaffected:
|
||||
```csharp
|
||||
public async Task WriteAsync(..., CancellationToken ct, ReportBranding? branding = null)
|
||||
```
|
||||
|
||||
**CRITICAL:** Do NOT change the `_togIdx` reset logic in `StorageHtmlExportService.BuildHtml` (see RESEARCH Pitfall 5).
|
||||
|
||||
**CRITICAL:** Every existing caller without the branding parameter must compile unchanged. The `= null` default handles this.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror</automated>
|
||||
</verify>
|
||||
<done>All 5 HTML export services accept optional ReportBranding parameter on BuildHtml and WriteAsync. BrandingHtmlHelper.BuildBrandingHeader is called between body and h1 in each. Build passes with zero warnings. No existing callers broken.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Extend export tests to verify branding injection</name>
|
||||
<files>
|
||||
SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs,
|
||||
SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs,
|
||||
SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs,
|
||||
SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs,
|
||||
SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs
|
||||
</files>
|
||||
<behavior>
|
||||
- Test 1 (HtmlExportServiceTests): BuildHtml with ReportBranding(mspLogo, null) produces HTML containing img tag with MSP logo data-URI
|
||||
- Test 2 (HtmlExportServiceTests): BuildHtml with null branding produces HTML that does NOT contain "branding-header" or data-URI img tags
|
||||
- Test 3 (HtmlExportServiceTests): BuildHtml with both logos produces HTML containing two img tags
|
||||
- Test 4 (SearchExportServiceTests): BuildHtml with branding contains img tag between body and h1
|
||||
- Test 5 (StorageHtmlExportServiceTests): BuildHtml with branding contains img tag
|
||||
- Test 6 (DuplicatesHtmlExportServiceTests): BuildHtml with branding contains img tag
|
||||
- Test 7 (UserAccessHtmlExportServiceTests): BuildHtml with branding contains img tag
|
||||
- Test 8 (regression): Each existing test still passes unchanged (no branding = same output)
|
||||
</behavior>
|
||||
<action>
|
||||
Add new test methods to each existing test file. Each test file already has helper methods for creating test data (e.g., `MakeEntry` in HtmlExportServiceTests). Use the same pattern.
|
||||
|
||||
Create a shared helper in each test class:
|
||||
```csharp
|
||||
private static ReportBranding MakeBranding(bool msp = true, bool client = false)
|
||||
{
|
||||
var mspLogo = msp ? new LogoData { Base64 = "bXNw", MimeType = "image/png" } : null;
|
||||
var clientLogo = client ? new LogoData { Base64 = "Y2xpZW50", MimeType = "image/jpeg" } : null;
|
||||
return new ReportBranding(mspLogo, clientLogo);
|
||||
}
|
||||
```
|
||||
|
||||
For HtmlExportServiceTests (the most thorough — 3 new tests):
|
||||
```csharp
|
||||
[Fact]
|
||||
public void BuildHtml_WithMspBranding_ContainsMspLogoImg()
|
||||
{
|
||||
var entry = MakeEntry("Test", "test@contoso.com");
|
||||
var svc = new HtmlExportService();
|
||||
var html = svc.BuildHtml(new[] { entry }, MakeBranding(msp: true, client: false));
|
||||
Assert.Contains("data:image/png;base64,bXNw", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHtml_WithNullBranding_ContainsNoLogoImg()
|
||||
{
|
||||
var entry = MakeEntry("Test", "test@contoso.com");
|
||||
var svc = new HtmlExportService();
|
||||
var html = svc.BuildHtml(new[] { entry });
|
||||
Assert.DoesNotContain("data:image/png;base64,", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHtml_WithBothLogos_ContainsTwoImgs()
|
||||
{
|
||||
var entry = MakeEntry("Test", "test@contoso.com");
|
||||
var svc = new HtmlExportService();
|
||||
var html = svc.BuildHtml(new[] { entry }, MakeBranding(msp: true, client: true));
|
||||
Assert.Contains("data:image/png;base64,bXNw", html);
|
||||
Assert.Contains("data:image/jpeg;base64,Y2xpZW50", html);
|
||||
}
|
||||
```
|
||||
|
||||
For each of the other 4 test files, add one test confirming branding injection works:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void BuildHtml_WithBranding_ContainsLogoImg()
|
||||
{
|
||||
// Use existing test data creation pattern from the file
|
||||
var svc = new XxxHtmlExportService();
|
||||
var html = svc.BuildHtml(testData, MakeBranding(msp: true));
|
||||
Assert.Contains("data:image/png;base64,bXNw", html);
|
||||
}
|
||||
```
|
||||
|
||||
Add `using SharepointToolbox.Core.Models;` to each test file if not present.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Export" --no-build -q</automated>
|
||||
</verify>
|
||||
<done>All 5 export test files have branding tests. Tests confirm: branding img tags appear when branding is provided, no img tags appear when branding is null. All existing export tests continue to pass (regression verified).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
dotnet build --no-restore -warnaserror
|
||||
dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Export" --no-build -q
|
||||
dotnet test SharepointToolbox.Tests --no-build -q
|
||||
```
|
||||
All three commands must pass with zero failures. The last command verifies no regressions across the full test suite.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All 5 HTML export services have optional ReportBranding? branding = null on BuildHtml and WriteAsync
|
||||
- Branding header is injected between body and h1 via BrandingHtmlHelper.BuildBrandingHeader call
|
||||
- Default null parameter preserves backward compatibility (existing callers compile unchanged)
|
||||
- Tests verify branding img tags appear when branding is provided
|
||||
- Tests verify no img tags appear when branding is null (identical to pre-branding output)
|
||||
- Full test suite passes with no regressions
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/11-html-export-branding/11-02-SUMMARY.md`
|
||||
</output>
|
||||
219
.planning/phases/11-html-export-branding/11-03-PLAN.md
Normal file
219
.planning/phases/11-html-export-branding/11-03-PLAN.md
Normal file
@@ -0,0 +1,219 @@
|
||||
---
|
||||
phase: 11-html-export-branding
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["11-02"]
|
||||
files_modified:
|
||||
- SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
|
||||
- SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs
|
||||
- SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs
|
||||
- SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs
|
||||
- SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
|
||||
- SharepointToolbox/App.xaml.cs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BRAND-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Each of the 5 export ViewModels injects IBrandingService and assembles ReportBranding before calling WriteAsync"
|
||||
- "ReportBranding is assembled from IBrandingService.GetMspLogoAsync() for MSP logo and _currentProfile.ClientLogo for client logo"
|
||||
- "The branding ReportBranding is passed as the last parameter to WriteAsync"
|
||||
- "DI container provides IBrandingService to all 5 export ViewModels"
|
||||
artifacts:
|
||||
- path: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
|
||||
provides: "Permissions export with branding assembly"
|
||||
contains: "IBrandingService"
|
||||
- path: "SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs"
|
||||
provides: "Search export with branding assembly"
|
||||
contains: "IBrandingService"
|
||||
- path: "SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs"
|
||||
provides: "Storage export with branding assembly"
|
||||
contains: "IBrandingService"
|
||||
- path: "SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs"
|
||||
provides: "Duplicates export with branding assembly"
|
||||
contains: "IBrandingService"
|
||||
- path: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
|
||||
provides: "User access export with branding assembly"
|
||||
contains: "IBrandingService"
|
||||
key_links:
|
||||
- from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
|
||||
to: "SharepointToolbox/Services/IBrandingService.cs"
|
||||
via: "constructor injection"
|
||||
pattern: "IBrandingService _brandingService"
|
||||
- from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
|
||||
to: "SharepointToolbox/Services/Export/HtmlExportService.cs"
|
||||
via: "WriteAsync with branding"
|
||||
pattern: "WriteAsync.*branding"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire IBrandingService into all 5 export ViewModels so each ExportHtmlAsync method assembles a ReportBranding from the MSP logo and the active tenant's client logo, then passes it to WriteAsync.
|
||||
|
||||
Purpose: Connects the branding infrastructure (Plan 01) and export service changes (Plan 02) to the user-facing export commands. After this plan, HTML exports include branding logos when configured.
|
||||
|
||||
Output: All 5 export ViewModels inject IBrandingService, assemble ReportBranding in ExportHtmlAsync, and pass it to WriteAsync.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/11-html-export-branding/11-CONTEXT.md
|
||||
@.planning/phases/11-html-export-branding/11-RESEARCH.md
|
||||
@.planning/phases/11-html-export-branding/11-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 11-01 -->
|
||||
From SharepointToolbox/Core/Models/ReportBranding.cs:
|
||||
```csharp
|
||||
public record ReportBranding(LogoData? MspLogo, LogoData? ClientLogo);
|
||||
```
|
||||
|
||||
<!-- From Phase 10 -->
|
||||
From SharepointToolbox/Services/IBrandingService.cs:
|
||||
```csharp
|
||||
public interface IBrandingService
|
||||
{
|
||||
Task<LogoData> ImportLogoAsync(string filePath);
|
||||
Task SaveMspLogoAsync(LogoData logo);
|
||||
Task ClearMspLogoAsync();
|
||||
Task<LogoData?> GetMspLogoAsync();
|
||||
}
|
||||
```
|
||||
|
||||
<!-- Current ViewModel patterns (all 5 follow this same shape) -->
|
||||
DuplicatesViewModel constructor pattern:
|
||||
```csharp
|
||||
public DuplicatesViewModel(
|
||||
IDuplicatesService duplicatesService,
|
||||
ISessionManager sessionManager,
|
||||
DuplicatesHtmlExportService htmlExportService,
|
||||
ILogger<FeatureViewModelBase> logger) : base(logger)
|
||||
```
|
||||
|
||||
Each ViewModel has:
|
||||
- `private TenantProfile? _currentProfile;` field set via OnTenantSwitched
|
||||
- `ExportHtmlAsync()` method calling `_htmlExportService.WriteAsync(..., CancellationToken.None)`
|
||||
|
||||
PermissionsViewModel has two constructors: full (DI) and test (internal, omits export services).
|
||||
UserAccessAuditViewModel also has two constructors.
|
||||
The other 3 ViewModels (Search, Storage, Duplicates) have a single constructor each.
|
||||
|
||||
DI registrations in App.xaml.cs:
|
||||
```csharp
|
||||
services.AddSingleton<IBrandingService, BrandingService>();
|
||||
```
|
||||
IBrandingService is already registered — no new DI registration needed for the service itself.
|
||||
But each ViewModel registration must now resolve IBrandingService in addition to existing deps.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Inject IBrandingService into all 5 export ViewModels and assemble branding in ExportHtmlAsync</name>
|
||||
<files>
|
||||
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs,
|
||||
SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs,
|
||||
SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs,
|
||||
SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs,
|
||||
SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
|
||||
</files>
|
||||
<action>
|
||||
Apply the same pattern to all 5 ViewModels:
|
||||
|
||||
**For each ViewModel:**
|
||||
|
||||
1. Add `using SharepointToolbox.Core.Models;` if not already present (needed for `ReportBranding`).
|
||||
|
||||
2. Add field: `private readonly IBrandingService _brandingService;`
|
||||
|
||||
3. Modify the DI constructor to accept `IBrandingService brandingService` parameter and assign `_brandingService = brandingService;`.
|
||||
|
||||
4. For ViewModels with a test constructor (PermissionsViewModel, UserAccessAuditViewModel): add `IBrandingService? brandingService = null` as the last parameter, assign `_brandingService = brandingService!;`. Using `null!` is acceptable because test constructors are only used in tests where branding is not exercised. Alternatively, create a no-op implementation — but `null!` matches existing pattern where `_htmlExportService = null` is already used in test constructors.
|
||||
|
||||
5. Modify `ExportHtmlAsync()` — add branding assembly BEFORE the WriteAsync call:
|
||||
```csharp
|
||||
// Assemble branding
|
||||
var mspLogo = await _brandingService.GetMspLogoAsync();
|
||||
var clientLogo = _currentProfile?.ClientLogo;
|
||||
var branding = new ReportBranding(mspLogo, clientLogo);
|
||||
```
|
||||
Then pass `branding` as the last argument to each `WriteAsync` call.
|
||||
|
||||
**Specific details per ViewModel:**
|
||||
|
||||
**PermissionsViewModel** (2 WriteAsync calls in ExportHtmlAsync):
|
||||
```csharp
|
||||
// Before:
|
||||
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None);
|
||||
// After:
|
||||
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding);
|
||||
```
|
||||
Same for the non-simplified path.
|
||||
|
||||
**SearchViewModel** (1 WriteAsync call):
|
||||
```csharp
|
||||
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding);
|
||||
```
|
||||
|
||||
**StorageViewModel** (1 WriteAsync call — the one with FileTypeMetrics):
|
||||
```csharp
|
||||
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None, branding);
|
||||
```
|
||||
|
||||
**DuplicatesViewModel** (1 WriteAsync call):
|
||||
```csharp
|
||||
await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None, branding);
|
||||
```
|
||||
|
||||
**UserAccessAuditViewModel** (1 WriteAsync call):
|
||||
```csharp
|
||||
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding);
|
||||
```
|
||||
|
||||
**Guard clause:** Add a null check on `_brandingService` before the branding assembly to be safe (in case the test constructor was used). If `_brandingService is null`, set `branding = null` (which means no branding header — graceful degradation):
|
||||
```csharp
|
||||
ReportBranding? branding = null;
|
||||
if (_brandingService is not null)
|
||||
{
|
||||
var mspLogo = await _brandingService.GetMspLogoAsync();
|
||||
var clientLogo = _currentProfile?.ClientLogo;
|
||||
branding = new ReportBranding(mspLogo, clientLogo);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --no-build -q</automated>
|
||||
</verify>
|
||||
<done>All 5 export ViewModels inject IBrandingService, assemble ReportBranding from MSP logo + active profile's ClientLogo, and pass it to WriteAsync. Build and all tests pass. Test constructors gracefully handle null IBrandingService.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
dotnet build --no-restore -warnaserror
|
||||
dotnet test SharepointToolbox.Tests --no-build -q
|
||||
```
|
||||
Both commands must pass. Full test suite must pass — existing ViewModel tests must not break from the constructor changes.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All 5 export ViewModels have IBrandingService injected via constructor
|
||||
- ExportHtmlAsync assembles ReportBranding from GetMspLogoAsync + _currentProfile.ClientLogo
|
||||
- ReportBranding is passed to WriteAsync as the last parameter
|
||||
- Test constructors handle null IBrandingService gracefully (branding = null fallback)
|
||||
- All existing ViewModel and export tests pass without modification
|
||||
- Build succeeds with zero warnings
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/11-html-export-branding/11-03-SUMMARY.md`
|
||||
</output>
|
||||
506
.planning/phases/11-html-export-branding/11-04-PLAN.md
Normal file
506
.planning/phases/11-html-export-branding/11-04-PLAN.md
Normal file
@@ -0,0 +1,506 @@
|
||||
---
|
||||
phase: 11-html-export-branding
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- SharepointToolbox/Services/ProfileService.cs
|
||||
- SharepointToolbox/Services/IBrandingService.cs
|
||||
- SharepointToolbox/Services/BrandingService.cs
|
||||
- SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs
|
||||
- SharepointToolbox/ViewModels/ProfileManagementViewModel.cs
|
||||
- SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs
|
||||
- SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs
|
||||
- SharepointToolbox.Tests/Services/ProfileServiceTests.cs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BRAND-04
|
||||
- BRAND-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "SettingsViewModel exposes BrowseMspLogoCommand and ClearMspLogoCommand that are exercisable without a View"
|
||||
- "ProfileManagementViewModel exposes BrowseClientLogoCommand, ClearClientLogoCommand, and AutoPullClientLogoCommand"
|
||||
- "ProfileService.UpdateProfileAsync persists changes to an existing profile (including ClientLogo)"
|
||||
- "AutoPullClientLogoCommand fetches squareLogo from Entra branding API and stores it as client logo"
|
||||
- "Auto-pull handles 404 (no Entra branding) gracefully with an informational message, no exception"
|
||||
- "BrandingService.ImportLogoFromBytesAsync validates and converts raw bytes to LogoData"
|
||||
artifacts:
|
||||
- path: "SharepointToolbox/Services/ProfileService.cs"
|
||||
provides: "UpdateProfileAsync method for persisting profile changes"
|
||||
contains: "UpdateProfileAsync"
|
||||
- path: "SharepointToolbox/Services/IBrandingService.cs"
|
||||
provides: "ImportLogoFromBytesAsync method declaration"
|
||||
contains: "ImportLogoFromBytesAsync"
|
||||
- path: "SharepointToolbox/Services/BrandingService.cs"
|
||||
provides: "ImportLogoFromBytesAsync implementation with magic byte validation"
|
||||
contains: "ImportLogoFromBytesAsync"
|
||||
- path: "SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs"
|
||||
provides: "MSP logo browse/clear commands"
|
||||
contains: "BrowseMspLogoCommand"
|
||||
- path: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||
provides: "Client logo browse/clear/auto-pull commands"
|
||||
contains: "AutoPullClientLogoCommand"
|
||||
- path: "SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs"
|
||||
provides: "Tests for MSP logo commands"
|
||||
min_lines: 40
|
||||
- path: "SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs"
|
||||
provides: "Tests for client logo commands and auto-pull"
|
||||
min_lines: 60
|
||||
key_links:
|
||||
- from: "SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs"
|
||||
to: "SharepointToolbox/Services/IBrandingService.cs"
|
||||
via: "constructor injection"
|
||||
pattern: "IBrandingService _brandingService"
|
||||
- from: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||
to: "SharepointToolbox/Services/ProfileService.cs"
|
||||
via: "UpdateProfileAsync call"
|
||||
pattern: "_profileService\\.UpdateProfileAsync"
|
||||
- from: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs"
|
||||
to: "Microsoft.Graph"
|
||||
via: "GraphClientFactory.CreateClientAsync"
|
||||
pattern: "Organization.*Branding.*SquareLogo"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add logo management commands to SettingsViewModel and ProfileManagementViewModel, add UpdateProfileAsync to ProfileService, add ImportLogoFromBytesAsync to BrandingService, and implement Entra branding auto-pull.
|
||||
|
||||
Purpose: BRAND-05 requires MSP logo management from Settings; BRAND-04 requires client logo management including auto-pull from tenant's Entra branding API. All commands must be exercisable without opening any View (ViewModel-testable).
|
||||
|
||||
Output: SettingsViewModel has browse/clear MSP logo commands, ProfileManagementViewModel has browse/clear/auto-pull client logo commands, ProfileService has UpdateProfileAsync, BrandingService has ImportLogoFromBytesAsync. All with unit tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/11-html-export-branding/11-CONTEXT.md
|
||||
@.planning/phases/11-html-export-branding/11-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From Phase 10 -->
|
||||
From SharepointToolbox/Services/IBrandingService.cs:
|
||||
```csharp
|
||||
public interface IBrandingService
|
||||
{
|
||||
Task<LogoData> ImportLogoAsync(string filePath);
|
||||
Task SaveMspLogoAsync(LogoData logo);
|
||||
Task ClearMspLogoAsync();
|
||||
Task<LogoData?> GetMspLogoAsync();
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/Services/BrandingService.cs:
|
||||
```csharp
|
||||
public class BrandingService : IBrandingService
|
||||
{
|
||||
private const int MaxSizeBytes = 512 * 1024;
|
||||
private static readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47 };
|
||||
private static readonly byte[] JpegMagic = { 0xFF, 0xD8, 0xFF };
|
||||
private readonly BrandingRepository _repository;
|
||||
// ImportLogoAsync reads file, validates magic bytes, compresses if >512KB
|
||||
// DetectMimeType private static — validates PNG/JPG magic bytes
|
||||
// CompressToLimit private static — WPF PresentationCore imaging
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/Services/ProfileService.cs:
|
||||
```csharp
|
||||
public class ProfileService
|
||||
{
|
||||
private readonly ProfileRepository _repository;
|
||||
public Task<IReadOnlyList<TenantProfile>> GetProfilesAsync();
|
||||
public async Task AddProfileAsync(TenantProfile profile);
|
||||
public async Task RenameProfileAsync(string existingName, string newName);
|
||||
public async Task DeleteProfileAsync(string name);
|
||||
// NOTE: No UpdateProfileAsync yet — must be added
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs:
|
||||
```csharp
|
||||
public partial class SettingsViewModel : FeatureViewModelBase
|
||||
{
|
||||
private readonly SettingsService _settingsService;
|
||||
public RelayCommand BrowseFolderCommand { get; }
|
||||
public SettingsViewModel(SettingsService settingsService, ILogger<FeatureViewModelBase> logger)
|
||||
// Uses OpenFolderDialog in BrowseFolder() — same pattern for logo browse
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/ViewModels/ProfileManagementViewModel.cs:
|
||||
```csharp
|
||||
public partial class ProfileManagementViewModel : ObservableObject
|
||||
{
|
||||
private readonly ProfileService _profileService;
|
||||
private readonly ILogger<ProfileManagementViewModel> _logger;
|
||||
[ObservableProperty] private TenantProfile? _selectedProfile;
|
||||
[ObservableProperty] private string _validationMessage = string.Empty;
|
||||
public ProfileManagementViewModel(ProfileService profileService, ILogger<ProfileManagementViewModel> logger)
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs:
|
||||
```csharp
|
||||
public class GraphClientFactory
|
||||
{
|
||||
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
Graph API for auto-pull (from RESEARCH):
|
||||
```csharp
|
||||
// Endpoint: GET /organization/{orgId}/branding/localizations/default/squareLogo
|
||||
var orgs = await graphClient.Organization.GetAsync();
|
||||
var orgId = orgs?.Value?.FirstOrDefault()?.Id;
|
||||
var stream = await graphClient.Organization[orgId]
|
||||
.Branding.Localizations["default"].SquareLogo.GetAsync();
|
||||
// Returns: Stream (image bytes), 404 if no branding, empty body if logo not set
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add UpdateProfileAsync to ProfileService and ImportLogoFromBytesAsync to BrandingService</name>
|
||||
<files>
|
||||
SharepointToolbox/Services/ProfileService.cs,
|
||||
SharepointToolbox/Services/IBrandingService.cs,
|
||||
SharepointToolbox/Services/BrandingService.cs,
|
||||
SharepointToolbox.Tests/Services/ProfileServiceTests.cs
|
||||
</files>
|
||||
<behavior>
|
||||
- Test 1: ProfileService.UpdateProfileAsync updates an existing profile and persists the change (round-trip through repository)
|
||||
- Test 2: ProfileService.UpdateProfileAsync throws KeyNotFoundException when profile name not found
|
||||
- Test 3: BrandingService.ImportLogoFromBytesAsync with valid PNG bytes returns LogoData with correct MimeType and Base64
|
||||
- Test 4: BrandingService.ImportLogoFromBytesAsync with invalid bytes throws InvalidDataException
|
||||
</behavior>
|
||||
<action>
|
||||
1. Add `UpdateProfileAsync` to `ProfileService.cs`:
|
||||
```csharp
|
||||
public async Task UpdateProfileAsync(TenantProfile profile)
|
||||
{
|
||||
var profiles = (await _repository.LoadAsync()).ToList();
|
||||
var idx = profiles.FindIndex(p => p.Name == profile.Name);
|
||||
if (idx < 0) throw new KeyNotFoundException($"Profile '{profile.Name}' not found.");
|
||||
profiles[idx] = profile;
|
||||
await _repository.SaveAsync(profiles);
|
||||
}
|
||||
```
|
||||
|
||||
2. Add `ImportLogoFromBytesAsync` to `IBrandingService.cs`:
|
||||
```csharp
|
||||
Task<LogoData> ImportLogoFromBytesAsync(byte[] bytes);
|
||||
```
|
||||
|
||||
3. Implement in `BrandingService.cs`:
|
||||
```csharp
|
||||
public Task<LogoData> ImportLogoFromBytesAsync(byte[] bytes)
|
||||
{
|
||||
var mimeType = DetectMimeType(bytes);
|
||||
|
||||
if (bytes.Length > MaxSizeBytes)
|
||||
{
|
||||
bytes = CompressToLimit(bytes, mimeType, MaxSizeBytes);
|
||||
}
|
||||
|
||||
return Task.FromResult(new LogoData
|
||||
{
|
||||
Base64 = Convert.ToBase64String(bytes),
|
||||
MimeType = mimeType
|
||||
});
|
||||
}
|
||||
```
|
||||
This extracts the validation/compression logic that `ImportLogoAsync` also uses. Refactor `ImportLogoAsync` to delegate to `ImportLogoFromBytesAsync` after reading the file:
|
||||
```csharp
|
||||
public async Task<LogoData> ImportLogoAsync(string filePath)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||
return await ImportLogoFromBytesAsync(bytes);
|
||||
}
|
||||
```
|
||||
|
||||
4. Extend `ProfileServiceTests.cs` (the file should already exist) with tests for `UpdateProfileAsync`. If it does not exist, create it following the same pattern as `BrandingRepositoryTests.cs` (IDisposable, temp file, real repository).
|
||||
|
||||
5. Add `ImportLogoFromBytesAsync` tests to existing `BrandingServiceTests.cs`. Create a valid PNG byte array (same technique as existing tests — 8-byte PNG signature + minimal IHDR/IEND) and verify the returned LogoData. Test invalid bytes throw `InvalidDataException`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~ProfileService|FullyQualifiedName~BrandingService" --no-build -q</automated>
|
||||
</verify>
|
||||
<done>ProfileService has UpdateProfileAsync that persists profile changes. BrandingService has ImportLogoFromBytesAsync for raw byte validation. ImportLogoAsync delegates to ImportLogoFromBytesAsync. All tests pass.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add MSP logo commands to SettingsViewModel and client logo commands to ProfileManagementViewModel</name>
|
||||
<files>
|
||||
SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs,
|
||||
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs,
|
||||
SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs,
|
||||
SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs
|
||||
</files>
|
||||
<action>
|
||||
**SettingsViewModel modifications:**
|
||||
|
||||
1. Add `using SharepointToolbox.Services;` if not already present. Add `using Microsoft.Win32;` (already present).
|
||||
|
||||
2. Add field: `private readonly IBrandingService _brandingService;`
|
||||
|
||||
3. Add properties:
|
||||
```csharp
|
||||
private string? _mspLogoPreview;
|
||||
public string? MspLogoPreview
|
||||
{
|
||||
get => _mspLogoPreview;
|
||||
private set { _mspLogoPreview = value; OnPropertyChanged(); }
|
||||
}
|
||||
```
|
||||
|
||||
4. Add commands:
|
||||
```csharp
|
||||
public IAsyncRelayCommand BrowseMspLogoCommand { get; }
|
||||
public IAsyncRelayCommand ClearMspLogoCommand { get; }
|
||||
```
|
||||
|
||||
5. Modify constructor to accept `IBrandingService brandingService` and initialize:
|
||||
```csharp
|
||||
public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ILogger<FeatureViewModelBase> logger)
|
||||
: base(logger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_brandingService = brandingService;
|
||||
BrowseFolderCommand = new RelayCommand(BrowseFolder);
|
||||
BrowseMspLogoCommand = new AsyncRelayCommand(BrowseMspLogoAsync);
|
||||
ClearMspLogoCommand = new AsyncRelayCommand(ClearMspLogoAsync);
|
||||
}
|
||||
```
|
||||
|
||||
6. Add `LoadAsync` extension — after loading settings, also load current MSP logo preview:
|
||||
```csharp
|
||||
// At end of existing LoadAsync:
|
||||
var mspLogo = await _brandingService.GetMspLogoAsync();
|
||||
MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null;
|
||||
```
|
||||
|
||||
7. Implement commands:
|
||||
```csharp
|
||||
private async Task BrowseMspLogoAsync()
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "Select MSP logo",
|
||||
Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg",
|
||||
};
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
var logo = await _brandingService.ImportLogoAsync(dialog.FileName);
|
||||
await _brandingService.SaveMspLogoAsync(logo);
|
||||
MspLogoPreview = $"data:{logo.MimeType};base64,{logo.Base64}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearMspLogoAsync()
|
||||
{
|
||||
await _brandingService.ClearMspLogoAsync();
|
||||
MspLogoPreview = null;
|
||||
}
|
||||
```
|
||||
|
||||
**ProfileManagementViewModel modifications:**
|
||||
|
||||
1. Add fields:
|
||||
```csharp
|
||||
private readonly IBrandingService _brandingService;
|
||||
private readonly Infrastructure.Auth.GraphClientFactory _graphClientFactory;
|
||||
```
|
||||
Add the type alias at the top of the file to avoid conflict with Microsoft.Graph.GraphClientFactory:
|
||||
```csharp
|
||||
using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory;
|
||||
```
|
||||
|
||||
2. Add commands:
|
||||
```csharp
|
||||
public IAsyncRelayCommand BrowseClientLogoCommand { get; }
|
||||
public IAsyncRelayCommand ClearClientLogoCommand { get; }
|
||||
public IAsyncRelayCommand AutoPullClientLogoCommand { get; }
|
||||
```
|
||||
|
||||
3. Modify constructor:
|
||||
```csharp
|
||||
public ProfileManagementViewModel(
|
||||
ProfileService profileService,
|
||||
IBrandingService brandingService,
|
||||
AppGraphClientFactory graphClientFactory,
|
||||
ILogger<ProfileManagementViewModel> logger)
|
||||
{
|
||||
_profileService = profileService;
|
||||
_brandingService = brandingService;
|
||||
_graphClientFactory = graphClientFactory;
|
||||
_logger = logger;
|
||||
|
||||
AddCommand = new AsyncRelayCommand(AddAsync, CanAdd);
|
||||
RenameCommand = new AsyncRelayCommand(RenameAsync, () => SelectedProfile != null && !string.IsNullOrWhiteSpace(NewName));
|
||||
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedProfile != null);
|
||||
BrowseClientLogoCommand = new AsyncRelayCommand(BrowseClientLogoAsync, () => SelectedProfile != null);
|
||||
ClearClientLogoCommand = new AsyncRelayCommand(ClearClientLogoAsync, () => SelectedProfile != null);
|
||||
AutoPullClientLogoCommand = new AsyncRelayCommand(AutoPullClientLogoAsync, () => SelectedProfile != null);
|
||||
}
|
||||
```
|
||||
|
||||
4. Update `NotifyCommandsCanExecuteChanged` and add `OnSelectedProfileChanged`:
|
||||
```csharp
|
||||
partial void OnSelectedProfileChanged(TenantProfile? value)
|
||||
{
|
||||
BrowseClientLogoCommand.NotifyCanExecuteChanged();
|
||||
ClearClientLogoCommand.NotifyCanExecuteChanged();
|
||||
AutoPullClientLogoCommand.NotifyCanExecuteChanged();
|
||||
RenameCommand.NotifyCanExecuteChanged();
|
||||
DeleteCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
```
|
||||
|
||||
5. Implement commands:
|
||||
```csharp
|
||||
private async Task BrowseClientLogoAsync()
|
||||
{
|
||||
if (SelectedProfile == null) return;
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "Select client logo",
|
||||
Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg",
|
||||
};
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
var logo = await _brandingService.ImportLogoAsync(dialog.FileName);
|
||||
SelectedProfile.ClientLogo = logo;
|
||||
await _profileService.UpdateProfileAsync(SelectedProfile);
|
||||
ValidationMessage = string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ValidationMessage = ex.Message;
|
||||
_logger.LogError(ex, "Failed to import client logo.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearClientLogoAsync()
|
||||
{
|
||||
if (SelectedProfile == null) return;
|
||||
try
|
||||
{
|
||||
SelectedProfile.ClientLogo = null;
|
||||
await _profileService.UpdateProfileAsync(SelectedProfile);
|
||||
ValidationMessage = string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ValidationMessage = ex.Message;
|
||||
_logger.LogError(ex, "Failed to clear client logo.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AutoPullClientLogoAsync()
|
||||
{
|
||||
if (SelectedProfile == null) return;
|
||||
try
|
||||
{
|
||||
var graphClient = await _graphClientFactory.CreateClientAsync(
|
||||
SelectedProfile.ClientId, CancellationToken.None);
|
||||
|
||||
var orgs = await graphClient.Organization.GetAsync();
|
||||
var orgId = orgs?.Value?.FirstOrDefault()?.Id;
|
||||
if (orgId is null)
|
||||
{
|
||||
ValidationMessage = "Could not determine organization ID.";
|
||||
return;
|
||||
}
|
||||
|
||||
var stream = await graphClient.Organization[orgId]
|
||||
.Branding.Localizations["default"].SquareLogo.GetAsync();
|
||||
|
||||
if (stream is null || stream.Length == 0)
|
||||
{
|
||||
ValidationMessage = "No branding logo found for this tenant.";
|
||||
return;
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
var logo = await _brandingService.ImportLogoFromBytesAsync(bytes);
|
||||
SelectedProfile.ClientLogo = logo;
|
||||
await _profileService.UpdateProfileAsync(SelectedProfile);
|
||||
ValidationMessage = "Client logo pulled from Entra branding.";
|
||||
}
|
||||
catch (Microsoft.Graph.Models.ODataErrors.ODataError ex) when (ex.ResponseStatusCode == 404)
|
||||
{
|
||||
ValidationMessage = "No Entra branding configured for this tenant.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ValidationMessage = $"Failed to pull logo: {ex.Message}";
|
||||
_logger.LogWarning(ex, "Auto-pull client logo failed.");
|
||||
}
|
||||
}
|
||||
```
|
||||
Add required usings: `using System.IO;`, `using Microsoft.Win32;`, `using Microsoft.Graph.Models.ODataErrors;`
|
||||
|
||||
**Tests:**
|
||||
|
||||
6. Create `SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs`:
|
||||
- Test that `BrowseMspLogoCommand` is not null after construction
|
||||
- Test that `ClearMspLogoCommand` is not null after construction
|
||||
- Test that `ClearMspLogoAsync` calls `IBrandingService.ClearMspLogoAsync` and sets `MspLogoPreview = null`
|
||||
- Use Moq to mock `IBrandingService` and `ILogger<FeatureViewModelBase>`
|
||||
- Cannot test `BrowseMspLogoAsync` fully (OpenFileDialog requires UI thread), but can test the command exists and ClearMspLogo path works
|
||||
|
||||
7. Create `SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs`:
|
||||
- Test that all 3 commands are not null after construction
|
||||
- Test `ClearClientLogoAsync`: mock ProfileService, set SelectedProfile, call command, verify ClientLogo is null and UpdateProfileAsync was called
|
||||
- Test `AutoPullClientLogoCommand` can execute check: false when SelectedProfile is null, true when set
|
||||
- Mock GraphClientFactory, IBrandingService, ProfileService, ILogger
|
||||
- Test auto-pull 404 handling: mock GraphServiceClient to throw ODataError with 404 status code, verify ValidationMessage is set and no exception propagates
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~SettingsViewModel|FullyQualifiedName~ProfileManagementViewModel" --no-build -q</automated>
|
||||
</verify>
|
||||
<done>SettingsViewModel has BrowseMspLogoCommand and ClearMspLogoCommand. ProfileManagementViewModel has BrowseClientLogoCommand, ClearClientLogoCommand, and AutoPullClientLogoCommand. ProfileService.UpdateProfileAsync persists profile changes. All commands are exercisable without View. Auto-pull handles 404 gracefully. All tests pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
dotnet build --no-restore -warnaserror
|
||||
dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~SettingsViewModel|FullyQualifiedName~ProfileManagementViewModel|FullyQualifiedName~ProfileService|FullyQualifiedName~BrandingService" --no-build -q
|
||||
dotnet test SharepointToolbox.Tests --no-build -q
|
||||
```
|
||||
All three commands must pass with zero failures.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SettingsViewModel exposes BrowseMspLogoCommand and ClearMspLogoCommand (IAsyncRelayCommand)
|
||||
- ProfileManagementViewModel exposes BrowseClientLogoCommand, ClearClientLogoCommand, AutoPullClientLogoCommand
|
||||
- ProfileService.UpdateProfileAsync updates and persists existing profiles
|
||||
- BrandingService.ImportLogoFromBytesAsync validates raw bytes and returns LogoData
|
||||
- ImportLogoAsync delegates to ImportLogoFromBytesAsync (no code duplication)
|
||||
- Auto-pull uses squareLogo endpoint, handles 404 gracefully with user message
|
||||
- All commands exercisable without View (ViewModel-testable)
|
||||
- Full test suite passes with no regressions
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/11-html-export-branding/11-04-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user