From 971ed10928f169b1cd7745e0f5ae0930040bf673 Mon Sep 17 00:00:00 2001 From: Ilia Sidorenko Date: Thu, 28 May 2026 20:53:15 -0400 Subject: [PATCH] collections: add 'duplicate' command for POST /collections/{id}/duplicate --- API-COVERAGE.md | 3 +- CHANGELOG.md | 1 + docs/public_api.json | 85 +++++++++++++++++++++++++++++- internal/api/collections.go | 16 ++++++ internal/api/collections_test.go | 26 +++++++++ internal/cli/collections.go | 27 ++++++++++ internal/cli/collections_test.go | 47 +++++++++++++++++ internal/mcp/tools_collections.go | 22 ++++++++ internal/skills/stqry-reference.md | 1 + 9 files changed, 226 insertions(+), 2 deletions(-) diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 98b5bc7..528acce 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -1,6 +1,6 @@ # API Coverage -> 138 of 138 API operations covered (100%): 135 direct CLI commands + 3 used internally — source: `docs/public_api.json` +> 139 of 139 API operations covered (100%): 136 direct CLI commands + 3 used internally — source: `docs/public_api.json` > > PATCH and PUT are collapsed into a single "update" operation throughout. > ✅ = direct CLI command · ⚠️ = used internally · ❌ = not implemented @@ -32,6 +32,7 @@ | GET | `/api/public/collections/{id}` | `stqry collections get ` | ✅ | | PATCH | `/api/public/collections/{id}` | `stqry collections update ` | ✅ | | DELETE | `/api/public/collections/{id}` | `stqry collections delete ` | ✅ | +| POST | `/api/public/collections/{id}/duplicate` | `stqry collections duplicate ` | ✅ | | GET | `/api/public/collections/{id}/appears_in` | `stqry collections appears-in ` | ✅ | ### Collection Items diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a6753..8622253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `stqry collections duplicate ` clones a collection via the new `POST /api/public/collections/{id}/duplicate` endpoint. Clones the collection, its items, triggers, map_routes and map_items; referenced Screens and MediaItems are *shared* with the source (shallow copy, same semantics as the Builder's duplicate). Prints the new collection — `--jq '.id'` grabs the new ID for follow-up edits. Previously the only way to copy a collection was to recreate it item-by-item via the CLI or fall back to the Builder. MCP gets a matching `duplicate_collection` tool. - `stqry screens sections remove --screen-id --lang fr` now drops just the named locale's translations on the section instead of removing the whole section. Mirrors the new screens / collections / media / projects per-locale delete surface. MCP's `delete_section` tool got an analogous optional `language` arg. - `stqry collections list`, `screens list`, `media list`, `projects list`, `codes list`, and `uploaded-files list` now expose `--sort-field ` and `--sort-direction asc|desc`. All six endpoints already accept the params per `docs/public_api.json`, but the CLI was forwarding only `page` / `per_page` / `q` (and `tags` on collections/projects). Closes the obvious "find the heaviest file" / "find the most recently updated screen" pattern that previously had to be done client-side via `--jq | sort`. `codes list` additionally gains `--q`, `--tags`, `--include-archived`, and `--valid-now`; `collections list` gains `--tags`. - `stqry screens delete --lang fr` and `stqry collections delete --lang fr` now drop just the named locale's translations instead of the whole record. The public API supports `DELETE /api/public/{screens,collections}/{id}?language=` (`docs/public_api.json` lists `language` as an optional DELETE param on both endpoints), but the CLI exposed it only on `media delete` and `projects delete` — so an agent wanting to "delete the French translations of this screen" had to drop the entire screen. Now four of the five delete endpoints with locale support carry the flag; `codes delete` doesn't because the spec doesn't list `language` on codes. Both new flags use the same defensive `cmd.Flags().Changed("lang")` gating as `projects delete`, so a bare `screens delete 42` never silently forwards the global --lang default. MCP's `delete_screen` / `delete_collection` tools got an analogous optional `language` arg. diff --git a/docs/public_api.json b/docs/public_api.json index 39c50fb..c8c3de2 100644 --- a/docs/public_api.json +++ b/docs/public_api.json @@ -1968,6 +1968,89 @@ } } }, + "/api/public/collections/{id}/duplicate": { + "post": { + "operationId": "CollectionsDuplicate", + "summary": "Duplicates a collection", + "tags": [ + "Collections" + ], + "parameters": [ + { + "description": "ID of the Collection to duplicate", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "ID of the Collection to duplicate", + "type": "integer" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "collection": { + "$ref": "#/components/schemas/Collection" + } + }, + "title": "CollectionsDuplicateResponse", + "type": "object" + } + } + }, + "description": "success" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Errors", + "title": "BadRequestResponse" + } + } + }, + "description": "Bad Request" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Errors", + "title": "UnauthorizedResponse" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Errors", + "title": "CollectionsDuplicateNotFound" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Errors", + "title": "CollectionsDuplicateUnprocessable" + } + } + }, + "description": "Unprocessable Content" + } + } + } + }, "/api/public/media_items": { "get": { "summary": "Returns a list of media_items for an account", @@ -11996,4 +12079,4 @@ } } } -} \ No newline at end of file +} diff --git a/internal/api/collections.go b/internal/api/collections.go index 0429ebb..07bf8e9 100644 --- a/internal/api/collections.go +++ b/internal/api/collections.go @@ -62,6 +62,22 @@ func UpdateCollection(c *Client, id string, fields map[string]interface{}) (map[ return resp, nil } +// DuplicateCollection clones a collection by ID. Clones the collection, its +// items, triggers, map_routes and map_items; referenced Screens and MediaItems +// are shared with the source (the same shallow semantics as the Builder's +// copyOf path). Maps to POST /api/public/collections/{id}/duplicate, which +// returns the new collection in the same envelope as GetCollection. +func DuplicateCollection(c *Client, id string) (map[string]interface{}, error) { + var resp map[string]interface{} + if err := c.Post(fmt.Sprintf("/api/public/collections/%s/duplicate", id), nil, &resp); err != nil { + return nil, err + } + if col, ok := resp["collection"].(map[string]interface{}); ok { + return col, nil + } + return resp, nil +} + // DeleteCollection deletes a collection by ID. Optional query supports the // per-locale delete via the public API's "language" param — pass // {"language": "fr"} to drop only the French translation instead of the diff --git a/internal/api/collections_test.go b/internal/api/collections_test.go index 7e4bfd3..3572b49 100644 --- a/internal/api/collections_test.go +++ b/internal/api/collections_test.go @@ -72,6 +72,32 @@ func TestGetCollection(t *testing.T) { } } +func TestDuplicateCollection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/public/collections/42/duplicate" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + json.NewEncoder(w).Encode(map[string]interface{}{ + "collection": map[string]interface{}{"id": 101, "name": "my-col (copy)"}, + }) + })) + defer server.Close() + + c := NewClient(server.URL, "test-token") + col, err := DuplicateCollection(c, "42") + if err != nil { + t.Fatalf("DuplicateCollection: %v", err) + } + if col["name"] != "my-col (copy)" { + t.Errorf("expected name=my-col (copy), got %v", col["name"]) + } +} + func TestCreateCollection(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { diff --git a/internal/cli/collections.go b/internal/cli/collections.go index 00f1088..bb5f5a0 100644 --- a/internal/cli/collections.go +++ b/internal/cli/collections.go @@ -116,6 +116,7 @@ func newCollectionsCmd() *cobra.Command { cmd.AddCommand(newCollectionsCreateCmd()) cmd.AddCommand(newCollectionsUpdateCmd()) cmd.AddCommand(newCollectionsDeleteCmd()) + cmd.AddCommand(newCollectionsDuplicateCmd()) appearsIn := newAppearsInCmd("collection", "collections", api.CollectionAppearsIn) appearsIn.ValidArgsFunction = completeCollectionIDs cmd.AddCommand(appearsIn) @@ -462,6 +463,32 @@ func newCollectionsDeleteCmd() *cobra.Command { return cmd } +func newCollectionsDuplicateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "duplicate ", + Short: "Duplicate a collection (clones items, triggers, and map data)", + Long: `Duplicate a collection. Clones the collection, its items, triggers, +map_routes and map_items; referenced Screens and MediaItems are shared with the +source (the same shallow semantics as the Builder's copy). Prints the new +collection.`, + Example: ` # Duplicate a collection; the new collection's details are printed + stqry collections duplicate 42 + + # Capture the new collection's ID for scripting + stqry collections duplicate 42 --jq '.id'`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + col, err := api.DuplicateCollection(activeClient, args[0]) + if err != nil { + return err + } + return printer.PrintOne(col, nil) + }, + } + cmd.ValidArgsFunction = completeCollectionIDs + return cmd +} + func newCollectionsItemsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "items", diff --git a/internal/cli/collections_test.go b/internal/cli/collections_test.go index 2f1252c..bde3053 100644 --- a/internal/cli/collections_test.go +++ b/internal/cli/collections_test.go @@ -1134,3 +1134,50 @@ func TestCollectionsItemsAddCmdGeofenceContentFalse(t *testing.T) { t.Errorf("expected geofence_content=false, got %v", gps["geofence_content"]) } } + +// TestCollectionsDuplicateCmd asserts that `collections duplicate ` POSTs +// to /collections/{id}/duplicate (no body) and prints the returned new +// collection. Pins the method+path so a regression to GET/PATCH or a dropped +// /duplicate suffix is caught. +func TestCollectionsDuplicateCmd(t *testing.T) { + var method, path string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method, path = r.Method, r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "collection": map[string]interface{}{"id": 101, "name": "Highlights (copy)"}, + }) + })) + defer server.Close() + setupTestHome(t, server.URL) + + origStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + cmd := newRootCmd() + cmd.SetArgs([]string{"collections", "duplicate", "42"}) + cmd.SetErr(os.Stderr) + execErr := cmd.Execute() + + w.Close() + os.Stdout = origStdout + outBytes := make([]byte, 4096) + n, _ := r.Read(outBytes) + r.Close() + out := string(outBytes[:n]) + + if execErr != nil { + t.Fatalf("Execute: %v", execErr) + } + if method != "POST" { + t.Errorf("expected POST, got %s", method) + } + if path != "/api/public/collections/42/duplicate" { + t.Errorf("unexpected path: %s", path) + } + if !contains(out, "Highlights (copy)") { + t.Errorf("expected new collection in output, got:\n%s", out) + } +} diff --git a/internal/mcp/tools_collections.go b/internal/mcp/tools_collections.go index 230a439..f027632 100644 --- a/internal/mcp/tools_collections.go +++ b/internal/mcp/tools_collections.go @@ -306,6 +306,28 @@ func registerCollectionTools(s *server.MCPServer, sess *Session) { }, ) + s.AddTool( + mcpgo.NewTool("duplicate_collection", + mcpgo.WithDescription("Duplicate a STQRY collection. Clones the collection, its items, triggers, map_routes and map_items; referenced Screens and MediaItems are shared with the source. Returns the new collection."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("The collection ID to duplicate")), + ), + func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + id := req.GetString("id", "") + if id == "" { + return mcpgo.NewToolResultError("id is required"), nil + } + client, err := ResolveClient(sess) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("resolving client: %v", err)), nil + } + collection, err := api.DuplicateCollection(client, id) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("duplicating collection: %v", err)), nil + } + return jsonResult(collection) + }, + ) + s.AddTool( mcpgo.NewTool("get_collection_appears_in", mcpgo.WithDescription("Show every place a collection is referenced (other collections, screens, app tabs, etc.)."), diff --git a/internal/skills/stqry-reference.md b/internal/skills/stqry-reference.md index 6a1e1a9..b2e82bd 100644 --- a/internal/skills/stqry-reference.md +++ b/internal/skills/stqry-reference.md @@ -334,6 +334,7 @@ stqry collections get Get a single collection stqry collections create --type [--name ] [--title ] [--short-title ] [--description ] [--tour-type ] [--cover-image-media-item-id ] [--cover-image-grid-media-item-id ] [--cover-image-wide-media-item-id ] [--logo-media-item-id ] [--preview-media-item-id ] [--map-view-enabled] [--primary-language ] Create a collection (--name or --title required). `name` and `title` are separate fields: `name` is a flat label, `title` is translatable. Pass --name for the label; add --title (with optional --lang) to populate the translatable title. The five `*-media-item-id` flags and `--map-view-enabled` mirror their `update` counterparts so a freshly created collection can already carry covers, logo, preview, and the map-view bit — saves a follow-up `collections update` for tour-build paths. `--primary-language` defaults to the resolved content language (i.e. --lang, which itself defaults to the account's `default_content_language`); pass it explicitly to seed an initial translation under one locale while declaring a different primary. stqry collections update [--primary-language ] ... Update a collection. Pass `--primary-language` to flip the resource's primary locale. Existing translation records on other locales are preserved, BUT the target locale must already have both `title` and `short_title` populated before the flip — the server validates the primary's translated fields on save and rejects the flip with `Title can't be blank; Short title can't be blank` otherwise. Populate the locale's title/short_title in a prior `update --lang --title ... --short-title ...` call, then run the primary-language flip. stqry collections delete [--lang ] Delete a collection entirely, or only one locale's translations with `--lang`. +stqry collections duplicate Duplicate a collection. Clones the collection, its items, triggers, map_routes and map_items; referenced Screens and MediaItems are **shared** with the source (shallow copy, same semantics as the Builder's duplicate) — editing a shared screen/media in the copy edits it in the original too. Prints the new collection; grab its ID with `--jq '.id'` to keep building on the copy. stqry collections appears-in Show every place this collection is referenced (other collections, project tabs, codes, etc.) — useful for orphan / unreferenced detection and safe-delete probes. stqry collections items list List items in a collection