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
3 changes: 2 additions & 1 deletion API-COVERAGE.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -32,6 +32,7 @@
| GET | `/api/public/collections/{id}` | `stqry collections get <id>` | ✅ |
| PATCH | `/api/public/collections/{id}` | `stqry collections update <id>` | ✅ |
| DELETE | `/api/public/collections/{id}` | `stqry collections delete <id>` | ✅ |
| POST | `/api/public/collections/{id}/duplicate` | `stqry collections duplicate <id>` | ✅ |
| GET | `/api/public/collections/{id}/appears_in` | `stqry collections appears-in <id>` | ✅ |

### Collection Items
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `stqry collections duplicate <id>` 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 <id> --screen-id <s> --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 <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 <id> --lang fr` and `stqry collections delete <id> --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=<lang>` (`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.
Expand Down
85 changes: 84 additions & 1 deletion docs/public_api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -11996,4 +12079,4 @@
}
}
}
}
}
16 changes: 16 additions & 0 deletions internal/api/collections.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions internal/api/collections_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
27 changes: 27 additions & 0 deletions internal/cli/collections.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -462,6 +463,32 @@ func newCollectionsDeleteCmd() *cobra.Command {
return cmd
}

func newCollectionsDuplicateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "duplicate <id>",
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",
Expand Down
47 changes: 47 additions & 0 deletions internal/cli/collections_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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)
}
}
22 changes: 22 additions & 0 deletions internal/mcp/tools_collections.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.)."),
Expand Down
1 change: 1 addition & 0 deletions internal/skills/stqry-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ stqry collections get <id> Get a single collection
stqry collections create --type <type> [--name <n>] [--title <t>] [--short-title <t>] [--description <d>] [--tour-type <tt>] [--cover-image-media-item-id <n>] [--cover-image-grid-media-item-id <n>] [--cover-image-wide-media-item-id <n>] [--logo-media-item-id <n>] [--preview-media-item-id <n>] [--map-view-enabled] [--primary-language <code>] 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 <id> [--primary-language <code>] ... 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 <code> --title ... --short-title ...` call, then run the primary-language flip.
stqry collections delete <id> [--lang <code>] Delete a collection entirely, or only one locale's translations with `--lang`.
stqry collections duplicate <id> 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 <id> 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 <collection-id> List items in a collection
Expand Down
Loading