package server import ( "slices" "strings" "github.com/PerpetualSoftware/pad/internal/collections" "github.com/PerpetualSoftware/pad/internal/models" ) // resolveItemCollectionSlug resolves a user-supplied collection slug against // the workspace's ACTUAL collections, so a regular singular/plural pair works // for any collection rather than only the seven the client-side alias map // happens to know (BUG-2578). // // "Regular" is the honest scope: only a trailing ASCII `s` is added or // removed, so `spec`/`specs` resolves and `category`/`categories` does not. // See collectionSlugCandidates for why that is a deliberate stopping point // rather than a gap to close with an inflector. // // Why this lives on the server. The alias map (collections.NormalizeSlug) is a // hardcoded switch over the DEFAULT templates' collection names, called from // the CLI and from the MCP dispatcher — both CLIENT side. It has no view of // the workspace, so a template-defined or user-created collection like `specs` // gets no singular form, and `pad item create spec` fails with "Collection not // found" for a collection that plainly exists in the caller's own bootstrap // payload. The workspace's collection list only exists here, so resolving here // fixes the CLI, the remote MCP transport, the web UI and any direct API // consumer at once, instead of teaching each client the same trick. // // EXACT MATCH ALWAYS WINS. The fallbacks run only when the input names no // collection at all, so this can never redirect a request that would otherwise // have succeeded — which is the property that makes it safe to add underneath // existing callers. (The client-side map does NOT have this property: it // rewrites before the server sees the input, so it can shadow a real // collection. That is BUG-2630, a separate defect this does not fix.) // // Scope is deliberate: item-facing entry points only — create, list, move and // bulk move, the surfaces the bug is about. In particular this is NOT wired // into store.GetCollectionBySlug, which has 23 call sites including // authorization paths (authz_cross_workspace, handlers_grants, // handlers_share_links). Fuzzy resolution inside a function used for // permission checks is how a check and the action it guards come to disagree // about which collection they mean. // // Returns (nil, nil) when nothing matches, exactly as GetCollectionBySlug // does, so callers keep their existing not-found handling. func (s *Server) resolveItemCollectionSlug(workspaceID, input string) (*models.Collection, error) { // The raw input first, then each fallback, and for EVERY name the same two // questions in the same order: is there a live collection with it, and // does an archived one claim it? // // Per-name rather than only for the raw input, because the fallbacks are // names too. With an archived `spec` and a live `specs`, `Spec` misses on // the raw form, and its case-folded candidate `spec` then finds no LIVE // row (GetCollectionBySlug skips soft-deleted) and walks on to `specs` — // so guarding only the input lets the exact archived name be stepped over // by a spelling of itself (codex round 8). for _, name := range append([]string{input}, collectionSlugCandidates(input)...) { coll, err := s.store.GetCollectionBySlug(workspaceID, name) if err != nil { return nil, err } if coll != nil { return coll, nil } // An ARCHIVED collection still CLAIMS its name. Falling through to a // different collection would quietly redirect writes the caller aimed // at a name that does exist, just not in a writable state — the same // trade this branch refuses on the client side in BUG-2630, and one // that survives a later restore by stranding items where they were // rerouted. archived, err := s.store.ArchivedCollectionClaimsSlug(workspaceID, name) if err != nil { return nil, err } if archived { return nil, nil } } return nil, nil } // collectionSlugCandidates returns the alternative slugs to try when `input` // matched nothing, in priority order and never including `input` itself. // // ASCII-`s` pluralization is tried first, then the fixed legacy alias map // (t/i/p/d, phase/phases). Neither reaches for an inflector: collection slugs // are generated by slugify and the plural convention is the house style, so // `s` covers the structural cases, while irregular-plural guessing // ("person" -> "people") would start inventing mappings a user never wrote. // Anything an inflector would catch, an exact slug still resolves. The alias // map is a CLOSED set carried over from the retired client-side normalizer, not // an open-ended guesser. func collectionSlugCandidates(input string) []string { trimmed := strings.ToLower(strings.TrimSpace(input)) if trimmed == "" { return nil } var out []string // A case-only difference comes FIRST. `Spec` names the collection `spec` // more closely than it names `specs`, so trying pluralization ahead of the // folded form would resolve `Spec` to `specs` in a workspace holding both // — the same misfiling the exact-match rule exists to prevent, reached by // a different route (codex round 3 P1). if trimmed != input { out = append(out, trimmed) } // Singular -> plural: the reported case (`spec` -> `specs`). out = append(out, trimmed+"s") // Plural -> singular, for a collection whose slug is genuinely singular // (`pad item create tasks` in a workspace whose collection is `task`). if s := strings.TrimSuffix(trimmed, "s"); s != trimmed && s != "" { out = append(out, s) } // The legacy SEMANTIC aliases the client-side map used to apply before // resolution moved to the server (BUG-2630): t/i/p/d -> tasks/ideas/plans/ // docs and phase/phases -> plans. Folded in LAST so the server owns the full // alias vocabulary and every client can send the raw slug — including the // remote MCP transport, which no longer normalizes (BUG-2630 #2). It is only // ever a last resort: the exact-match and the archived-claims refusal in // resolveItemCollectionSlug run for `input` and every candidate BEFORE this // one is reached, so a real (or archived-claimed) collection of the input // name still wins, and this never shadows or redirects around it. Aliases // that duplicate a structural candidate above (e.g. plan -> plans) are // dropped so the resolve loop does not query the same slug twice. if alias := collections.NormalizeSlug(trimmed); alias != trimmed && !slices.Contains(out, alias) { out = append(out, alias) } return out }