Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ dotnet run --project src\ModelPublisher.Cli -- "C:\Users\chris\Downloads\Models\
| `src/ModelPublisher.Core/Shared/AuthGuard.cs` | Pauses for human login when not authenticated |
| `src/ModelPublisher.Core/Shared/FileUploadHelper.cs` | `UploadSequentialAsync` — uploads one file at a time, waits for NetworkIdle |
| `src/ModelPublisher.Core/Shared/MarkdownHelper.cs` | `ToPlainText` and `ToTipTapHtml` — converts markdown for platforms that need it |
| `src/ModelPublisher.Core/Models/ReleaseManifest.cs` | Deserializes manifest.json |
| `src/ModelPublisher.Core/Models/ReleaseManifest.cs` | Deserializes manifest.json; `GetPlatformConfig<T>()` deserializes typed platform config |
| `src/ModelPublisher.Core/Models/PlatformConfig.cs` | Base record with `Tier` + `PrintProfiles` — all platform configs inherit from this |
| `src/ModelPublisher.Core/Platforms/PatreonConfig.cs` | Patreon-specific config: `FreePost`, `AccessTierId` |
| `src/ModelPublisher.Core/Models/PublishResult.cs` | Result record — `Tier` is set by orchestrator via `with`, not by publishers |

## Manifest format
Expand All @@ -42,12 +44,23 @@ dotnet run --project src\ModelPublisher.Cli -- "C:\Users\chris\Downloads\Models\
"photos": ["./cover-photo.jpg", "./detail.jpg"]
},
"platforms": {
"printables": { "tier": "free" }
"printables": {
"tier": "free",
"print_profiles": ["./profiles/printables-0.2mm.3mf"]
},
"patreon": {
"tier": "premium",
"free_post": false,
"access_tier_id": "YOUR_TIER_ID"
}
}
}
```
- `cover` is optional. If set, `PhotosOrdered(coverFirst)` deduplicates and positions it.
- `manifest.ManifestDirectory` is set after deserialization; use `ResolveFilePath()` for all file paths.
- `print_profiles` is optional on any platform; defaults to `[]`. Paths are relative to manifest dir.
- To read typed config in a publisher: `manifest.GetPlatformConfig<PlatformConfig>(PlatformKey)` (returns `null` if platform not listed). Use a subclass (e.g. `PatreonConfig`) for platform-specific fields.
- `Platforms` stays `Dictionary<string, JsonElement>` internally — `GetPlatformConfig<T>` deserializes on demand.

## Platform status
| Key | Platform | Status |
Expand Down Expand Up @@ -79,6 +92,12 @@ dotnet run --project src\ModelPublisher.Cli -- "C:\Users\chris\Downloads\Models\
- **Spectre.Console**: any string containing `[` or `]` from user data must be wrapped in `Markup.Escape()`.
- **System.CommandLine 2.0.3**: `SetAction` + `ParseResult.GetValue` only — old `Handler` API removed.

## Slopwatch
- Installed globally: `dotnet tool install --global Slopwatch.Cmd` (v0.4.0)
- Baseline initialized at `.slopwatch/baseline.json` (0 pre-existing issues on master)
- Run after code changes: `powershell.exe -Command "cd 'C:\Source\ModelPublisher'; slopwatch analyze -d ."`
- Detects: disabled tests, empty catch blocks, warning suppression, arbitrary delays, NoWarn in csproj, CPM bypass

## GitHub workflow
- Repo: https://github.com/TheCraftyMaker/ModelPublisher
- `master` is protected — PRs required, no direct pushes, enforce_admins=true
Expand Down
46 changes: 45 additions & 1 deletion ModelPublisher.sln
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@


Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
Expand All @@ -7,19 +7,63 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelPublisher.Cli", "src\M
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelPublisher.Core", "src\ModelPublisher.Core\ModelPublisher.Core.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelPublisher.Core.Tests", "tests\ModelPublisher.Core.Tests\ModelPublisher.Core.Tests.csproj", "{B3713944-A5ED-4A22-81E6-646CA6369288}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.Build.0 = Release|Any CPU
{B3713944-A5ED-4A22-81E6-646CA6369288}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B3713944-A5ED-4A22-81E6-646CA6369288}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B3713944-A5ED-4A22-81E6-646CA6369288}.Debug|x64.ActiveCfg = Debug|Any CPU
{B3713944-A5ED-4A22-81E6-646CA6369288}.Debug|x64.Build.0 = Debug|Any CPU
{B3713944-A5ED-4A22-81E6-646CA6369288}.Debug|x86.ActiveCfg = Debug|Any CPU
{B3713944-A5ED-4A22-81E6-646CA6369288}.Debug|x86.Build.0 = Debug|Any CPU
{B3713944-A5ED-4A22-81E6-646CA6369288}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B3713944-A5ED-4A22-81E6-646CA6369288}.Release|Any CPU.Build.0 = Release|Any CPU
{B3713944-A5ED-4A22-81E6-646CA6369288}.Release|x64.ActiveCfg = Release|Any CPU
{B3713944-A5ED-4A22-81E6-646CA6369288}.Release|x64.Build.0 = Release|Any CPU
{B3713944-A5ED-4A22-81E6-646CA6369288}.Release|x86.ActiveCfg = Release|Any CPU
{B3713944-A5ED-4A22-81E6-646CA6369288}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{B3713944-A5ED-4A22-81E6-646CA6369288} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
150 changes: 150 additions & 0 deletions docs/specs/platform-config-print-profiles/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Design: Platform-Specific Config with Print Profiles

**Date:** 2026-03-14
**Status:** Approved

## Problem

Publishers need per-platform file attachments (print profiles — typically `.3mf` files) alongside the existing common fields. The current manifest `Platforms` dictionary stores raw `JsonElement` values, requiring manual property extraction. There is no shared model for platform config, and no canonical place to put shared optional fields like print profiles.

## Goal

- Add `print_profiles` as a list of relative file paths on each platform's config block.
- Introduce a typed `PlatformConfig` base record so publishers get IDE-supported, type-safe access to platform config.
- Keep the JSON manifest format backward-compatible (existing manifests without `print_profiles` continue to work).
- Make it easy to add future platform-specific fields without touching shared code.

## Non-Goals

- Uploading or validating print profile files (responsibility of each publisher).
- Changing the platform key format or the `Platforms` dictionary structure.

---

## Architecture

### New: `Models/PlatformConfig.cs`

Shared base record for all platform config blocks:

```csharp
public record PlatformConfig
{
[JsonPropertyName("tier")]
public string Tier { get; init; } = "free";

[JsonPropertyName("print_profiles")]
public List<string> PrintProfiles { get; init; } = [];
}
```

`PrintProfiles` contains relative paths resolved via the existing `manifest.ResolveFilePath()`.

### New: `Platforms/PatreonConfig.cs`

Moves Patreon's existing extra fields from ad-hoc `JsonElement` access into a typed subclass:

```csharp
public record PatreonConfig : PlatformConfig
{
[JsonPropertyName("free_post")]
public bool FreePost { get; init; } = true;

[JsonPropertyName("access_tier_id")]
public string? AccessTierId { get; init; }
}
```

### Updated: `Models/ReleaseManifest.cs`

`Platforms` stays `Dictionary<string, JsonElement>` — no change to deserialization or JSON format.

Add one helper method:

```csharp
public T? GetPlatformConfig<T>(string platformKey) where T : PlatformConfig, new()
{
if (!Platforms.TryGetValue(platformKey, out var el))
return null;
return JsonSerializer.Deserialize<T>(el, JsonOptions) ?? new T();
}
```

Returns `null` when the platform key is absent — preserving the existing gate in `PublishCommand` where `.Where(x => x.Tier != null)` filters out unlisted platforms. A private static `JsonOptions` with `PropertyNameCaseInsensitive = true` is added to the class (deserialization only; `WriteIndented` is irrelevant here). No custom converter is needed: `JsonSerializer.Deserialize<PatreonConfig>(el, options)` works correctly for concrete derived types without any `[JsonDerivedType]` attributes.

### Updated: `PublishCommand.cs`

`ResolveTier` replaces its manual `JsonElement` property extraction with:

```csharp
var config = manifest.GetPlatformConfig<PlatformConfig>(publisher.PlatformKey);
if (config is null) return null;
return config.Tier is "free" or "premium" ? config.Tier : "free";
```

The `null` return when the key is absent is preserved — `PublishCommand`'s existing `.Where(x => x.Tier != null)` gate continues to filter out platforms not listed in the manifest.

### Updated: `Platforms/PatreonPublisher.cs`

`PatreonPublisher` currently has no `JsonElement` access — its extra fields (`free_post`, `access_tier_id`) are not yet read from the manifest. This change adds first-time typed access: the publisher calls `manifest.GetPlatformConfig<PatreonConfig>(PlatformKey)` so that when those fields are implemented they use the typed config rather than raw `JsonElement`.

---

## Manifest JSON Format

No breaking changes. `print_profiles` is optional and defaults to an empty list when omitted. Platforms that do not use print profiles simply leave the key out — `PlatformConfig.PrintProfiles` will be `[]`.

```json
"platforms": {
"printables": {
"tier": "free",
"print_profiles": ["./profiles/printables-0.2mm.3mf"]
},
"makerworld": {
"tier": "free",
"print_profiles": [
"./profiles/makerworld-0.2mm.3mf",
"./profiles/makerworld-0.4mm.3mf"
]
},
"patreon": {
"tier": "premium",
"free_post": false,
"access_tier_id": "YOUR_TIER_ID_HERE"
// print_profiles omitted — defaults to []
}
}
```

Note: path resolution for print profiles uses the existing `manifest.ResolveFilePath()`. File existence is not validated at load time — that is the responsibility of each publisher at upload time.

---

## Data Flow

1. `PublishCommand` calls `manifest.GetPlatformConfig<PlatformConfig>(publisher.PlatformKey)` to resolve tier.
2. Publisher receives `manifest` as before.
3. Publisher calls `manifest.GetPlatformConfig<T>(PlatformKey)` to get its typed config.
4. Publisher iterates `config.PrintProfiles`, calls `manifest.ResolveFilePath(path)` on each to get absolute paths.
5. Publisher uploads resolved paths using the existing `FileUploadHelper`.

---

## Adding Future Platform-Specific Fields

- If a platform needs unique fields: create `MyPlatformConfig : PlatformConfig` and call `GetPlatformConfig<MyPlatformConfig>()`.
- If a field is useful across all platforms: add it to `PlatformConfig` directly.
- No changes required to `ReleaseManifest`, `PublishCommand`, or any other publisher.

---

## Files Changed

| File | Change |
|------|--------|
| `src/ModelPublisher.Core/Models/PlatformConfig.cs` | **New** — base record |
| `src/ModelPublisher.Core/Platforms/PatreonConfig.cs` | **New** — Patreon-specific subclass |
| `src/ModelPublisher.Core/Models/ReleaseManifest.cs` | Add `GetPlatformConfig<T>()` helper |
| `src/ModelPublisher.Core/PublishCommand.cs` | Simplify `ResolveTier` |
| `src/ModelPublisher.Core/Platforms/PatreonPublisher.cs` | Use `PatreonConfig` instead of raw `JsonElement` |
| `releases/example-model/manifest.json` | Add `print_profiles` example entries |
Loading
Loading