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
84 changes: 84 additions & 0 deletions docs/superpowers/handoffs/2026-07-27-dts-editor-agnostic-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Editor Handoff — Platform-Agnostic (help) Templates & Readonly Visibility

**For:** the DTS template editor (`~/dev/poracle-embed-visualizer`).
**From:** PoracleNG processor, branch `fix/dts-agnostic-platform-save` (PR #176).
**Date:** 2026-07-27.

## The bug this closes
Loading a fallback **help** template (e.g. `help/fort`) in the editor and saving it to edit later produced a **duplicate** in the editable-templates list: the readonly fallback **plus** a second `discord` copy. Root cause was two-sided:

- **Server** rejected `platform=""` on save, forcing help to be saved with a concrete platform.
- **Editor** coerces an empty platform to `"discord"` on load, so the agnostic help fallback (`platform=""`) becomes a `discord`-specific entry that can't shadow the `""` fallback (the override key includes platform).

The **server half is shipped** (see "Server contract" below). This doc is the **editor half**.

## Key fact: `help` is the ONLY platform-agnostic type
Every other DTS type — `monster`, `monsterNoIv`, `raid`, `egg`, `quest`, `questSummary`, `invasion`, `incident`, `lure`, `nest`, `gym`, `fort-update`, `maxbattle`, `showcase`, `monsterChanged`, `weatherchange`, `rsvpChanges`, `buttonResponse` — is **platform-specific** and MUST carry a concrete platform (`discord`/`telegram`). Only `help` is agnostic: its bundled fallbacks ship with `platform=""` (from `fallbacks/dts/help/*.json`) and a single entry serves all platforms.

Hardcode this to match the server:

```js
const AGNOSTIC_TYPES = new Set(['help']);
const isAgnostic = (type) => AGNOSTIC_TYPES.has(type);
```

(The server's equivalent is `dts.IsPlatformAgnosticType`, currently `{help}`. There is no API to query the set — keep this client list in sync if the server ever adds more agnostic types.)

## Editor changes

### 1. Stop coercing empty platform to `"discord"` for agnostic types
`src/hooks/useDts.js:8`:

```js
.map((e) => ({ ...e, id: String(e.id ?? '1'), platform: e.platform || 'discord', language: e.language ?? '' }));
```

`e.platform || 'discord'` is what turns the fallback's `""` into `"discord"`. Preserve `""` for agnostic types:

```js
.map((e) => ({
...e,
id: String(e.id ?? '1'),
platform: isAgnostic(e.type) ? (e.platform ?? '') : (e.platform || 'discord'),
language: e.language ?? '',
}));
```

Apply the same to the other coercion sites: `useDts.js:180` (`platform: template.platform || 'discord'`) and the new-template default at `useDts.js:15` (`platform: 'discord'` — a **new** help template should default to `""`, not `discord`).

### 2. Show agnostic entries regardless of the platform tab
The list filters by `t.platform === filters.platform` (`useDts.js:32, 41, 47, 70, 81, 142`). An agnostic help entry (`platform=""`) fails `"" === "discord"` and would vanish from every platform tab. Include agnostic entries in any tab:

```js
const platformMatches = (t) => isAgnostic(t.type) || t.platform === filters.platform;
```

(Or give `help` its own platform-neutral view — but "show in every tab" is the least surprising.)

### 3. Save agnostic templates with `platform=""`
When saving a help template, POST `platform: ""` (not `"discord"`). With change #1 preserving `""` through state, this happens naturally as long as the save path doesn't re-inject a platform. Server-side the file is then written as `config/dts/help-fort.json` (no platform segment).

Also fix the **download** filename at `src/App.jsx:319`:

```js
a.download = `${entry.type}-${entry.id || 'default'}-${entry.platform || 'discord'}.json`;
```

Omit the platform segment when empty so an agnostic download is `help-fort.json` (mirrors the server's `entryFilename`).

### 4. Show readonly (fallback) entries in the list, clearly marked
`GET /api/dts/templates` returns **`readonly: true`** on every bundled/fallback entry. **Surface these in the template list — don't hide them** — with a clear badge such as **"read-only (fallback)"** (and, ideally, an affordance to "copy / override"). This makes it obvious which rows are:

- editable in place (the user's own `config/dts/` entries, `readonly` absent/false), vs
- read-only fallbacks the user can copy to create an override.

When the user saves an override of a readonly fallback, the server **drops the readonly entry** from the returned list (the user's copy shadows it), so the row naturally flips from "read-only" to editable — no duplicate. Rendering the `readonly` flag is what makes that transition legible to the user.

## Server contract (already shipped — PR #176)
- `POST /api/dts/templates` now **accepts `platform=""` for agnostic types** (`help`). Non-agnostic types still return **400** without a platform (unchanged).
- A saved `(help, <id>, "")` override shares the fallback's key and **shadows** it: the editable list then shows exactly **one** `help/<id>` entry (the user's), with the readonly fallback dropped.
- Saved agnostic files are named `help-<id>.json` (no platform segment).
- `GET /api/dts/templates` continues to return `readonly: true` on fallback entries — use it for the badge in change #4.

## Migration note (existing duplicate)
The duplicate currently visible on the running instance comes from an already-saved `config/dts/help-fort-discord.json` (platform `discord`) written before this fix. Delete it (editor delete button or `rm`) to clear the current duplicate; the fix only prevents **new** ones.
6 changes: 5 additions & 1 deletion processor/internal/api/huma_dts_writes.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,11 @@ func RegisterDTSSaveTemplates(api huma.API, ts dtsSaveWriter) {
}

for i, entry := range entries {
if entry.Type == "" || entry.Platform == "" {
// Platform is required except for platform-agnostic types (e.g.
// help), whose fallbacks carry an empty platform. Allowing "" for
// those lets an override keep the fallback's key and shadow it,
// instead of surfacing as a duplicate alongside the fallback.
if entry.Type == "" || (entry.Platform == "" && !dts.IsPlatformAgnosticType(entry.Type)) {
msg := fmt.Sprintf("entry %d missing required fields (type=%q, platform=%q, id=%q)", i, entry.Type, entry.Platform, entry.ID)
log.Warnf("dts save: %s", msg)
return nil, huma.Error400BadRequest(msg)
Expand Down
13 changes: 13 additions & 0 deletions processor/internal/api/huma_dts_writes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,19 @@ func TestHumaDTSSaveTemplates_MissingFields400(t *testing.T) {
}
}

func TestHumaDTSSaveTemplates_AgnosticHelpEmptyPlatformOK(t *testing.T) {
r, api := newFeaturesTestAPI(t)
RegisterDTSSaveTemplates(api, &stubDTSWriter{})

// help is platform-agnostic: platform="" must be accepted (not 400) so an
// override can shadow the "" fallback instead of duplicating it.
body := []byte(`[{"type":"help","id":"fort","platform":"","template":"x"}]`)
w := postJSON(t, r, "/api/dts/templates", body)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for agnostic help with empty platform, got %d body=%s", w.Code, w.Body.String())
}
}

func TestHumaDTSSaveTemplates_Readonly403(t *testing.T) {
r, api := newFeaturesTestAPI(t)
ts := &stubDTSWriter{saveErr: errors.New("cannot overwrite readonly entry")}
Expand Down
62 changes: 62 additions & 0 deletions processor/internal/dts/editor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,68 @@ func TestFilteredEntriesDedupesOverride(t *testing.T) {
}
}

// TestSaveEntryAgnosticHelpOverrideShadowsFallback covers the "duplicate
// help/fort in the editor list" bug: a platform-agnostic help fallback
// (platform="") saved as an override must keep platform="" so its entryKey
// matches the fallback and it shadows it — rather than surfacing as a second
// entry. Previously the editor was forced to save help with platform="discord"
// (the save API rejected ""), producing (help,fort,"") + (help,fort,discord).
func TestSaveEntryAgnosticHelpOverrideShadowsFallback(t *testing.T) {
entries := []DTSEntry{
{Type: "help", ID: "fort", Platform: "", Language: "", Template: "fallback text", Readonly: true},
}
ts, tmp := newTestStore(t, entries)

// Save an agnostic override (platform="" preserved, as the fixed editor sends).
err := ts.SaveEntry(DTSEntry{Type: "help", ID: "fort", Platform: "", Language: "", Template: "my edited text"})
if err != nil {
t.Fatalf("SaveEntry: %v", err)
}

// The editor list for help/fort must show exactly ONE entry — the user's.
got := ts.FilteredEntries("help", "", "", "fort")
if len(got) != 1 {
t.Fatalf("expected 1 help/fort entry after override, got %d (%+v)", len(got), got)
}
if got[0].Readonly {
t.Errorf("help/fort entry is readonly; want the user override to shadow the fallback")
}
if got[0].Template != "my edited text" {
t.Errorf("template = %v, want the saved override text", got[0].Template)
}

// The override is written with a clean, platform-less filename.
if _, err := os.Stat(filepath.Join(tmp, "dts", "help-fort.json")); err != nil {
t.Errorf("expected config/dts/help-fort.json to exist: %v", err)
}
}

func TestEntryFilenameEmptyPlatform(t *testing.T) {
agnostic := entryFilename(&DTSEntry{Type: "help", ID: "fort", Platform: ""})
if agnostic != "help-fort.json" {
t.Errorf("agnostic filename = %q, want %q", agnostic, "help-fort.json")
}
withPlatform := entryFilename(&DTSEntry{Type: "monster", ID: "1", Platform: "discord"})
if withPlatform != "monster-1-discord.json" {
t.Errorf("platform filename = %q, want %q", withPlatform, "monster-1-discord.json")
}
withLang := entryFilename(&DTSEntry{Type: "help", ID: "fort", Platform: "", Language: "de"})
if withLang != "help-fort-de.json" {
t.Errorf("agnostic+lang filename = %q, want %q", withLang, "help-fort-de.json")
}
}

func TestIsPlatformAgnosticType(t *testing.T) {
if !IsPlatformAgnosticType("help") {
t.Error(`IsPlatformAgnosticType("help") = false, want true`)
}
for _, notAgnostic := range []string{"monster", "raid", "quest", ""} {
if IsPlatformAgnosticType(notAgnostic) {
t.Errorf("IsPlatformAgnosticType(%q) = true, want false", notAgnostic)
}
}
}

func TestGetEntryPrefersLast(t *testing.T) {
entries := []DTSEntry{
{Type: "monster", ID: "1", Platform: "discord", Language: "", Template: "old"},
Expand Down
25 changes: 23 additions & 2 deletions processor/internal/dts/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,22 @@ func entryKey(e *DTSEntry) string {
return e.Type + "|" + e.Platform + "|" + e.Language + "|" + strings.ToLower(e.ID.String())
}

// platformAgnosticTypes are DTS template types whose bundled fallbacks carry
// an empty platform ("") — they render the same regardless of destination
// platform, so a user override should stay platform-agnostic too. Keeping an
// override agnostic makes its entryKey match the fallback's, so the override
// cleanly shadows the fallback instead of appearing as a second entry. Today
// only "help" (per-command help text, loaded from fallbacks/dts/help/*.json)
// is agnostic; add future agnostic types here.
var platformAgnosticTypes = map[string]bool{"help": true}

// IsPlatformAgnosticType reports whether a DTS type is platform-agnostic and
// may therefore be saved with an empty platform. Non-agnostic types (monster,
// raid, …) still require a concrete platform on save.
func IsPlatformAgnosticType(dtsType string) bool {
return platformAgnosticTypes[dtsType]
}

// dedupEntriesPreferLast returns a slice containing each entry keyed by
// entryKey, keeping only the last occurrence. Load order is
// fallback → config/dts.json → config/dts/*.json, so the "last" entry is
Expand Down Expand Up @@ -1098,13 +1114,18 @@ func dedupEntriesPreferLast(entries []DTSEntry) []DTSEntry {
}

// entryFilename generates a filename for saving an entry to config/dts/.
// Format: {type}-{id}-{platform}[-{lang}].json
// Format: {type}-{id}[-{platform}][-{lang}].json. The platform segment is
// omitted for platform-agnostic entries (empty platform) so they get a clean
// "help-fort.json" rather than a trailing-dash "help-fort-.json".
func entryFilename(e *DTSEntry) string {
id := strings.ToLower(e.ID.String())
if id == "" {
id = "default"
}
name := e.Type + "-" + id + "-" + e.Platform
name := e.Type + "-" + id
if e.Platform != "" {
name += "-" + e.Platform
}
if e.Language != "" {
name += "-" + e.Language
}
Expand Down