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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `stqry screens sections` gains quiz authoring and several ergonomic fixes: `add` / `update` can author `quiz_question` / `quiz_score` sections (`--quiz-question-id` / `--quiz-id`, optional `--zoomable`; `update` can re-point an existing section); `<kind> list` (badges / links / media / prices / social / hours) accepts the screen id positionally; `remove` emits a projectable `{"deleted": true, "id": <id>, "screen_id": <id>}` record in machine modes; `--lang` paired with the flat, non-translatable `--name` field now warns on stderr; the `sections add --position <n>` help/reference now documents that the server inserts at the given slot and shifts existing siblings down (positions stay contiguous, no follow-up `sections reorder` needed); and `--jq` in `--raw` mode emits nothing (instead of literal `null`) for a null result. MCP's `create_section` gained the same quiz support.
- The `--lang` whitelist now covers the full set of STQRY-supported content languages, generated from the server's accepted set rather than a hand-maintained subset, so any language the platform accepts is no longer rejected client-side.
- `stqry quizzes` — full CRUD for the Quizzes Public API across the quiz → questions → answers hierarchy, with matching MCP tools, shell completion, and reference/coverage docs. Translated fields respect `--lang`, `--question-type` is validated client-side, answers carry a `--correct` flag, and `delete`/`remove` accept `--lang` for per-locale translation deletes.

Expand Down
20 changes: 20 additions & 0 deletions internal/cli/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,23 @@ func printHumanConfirmation(format string, args ...interface{}) {
}
fmt.Printf(format, args...)
}

// printDeletion reports a successful delete/remove. In machine modes
// (`--json`, `--quiet`, `--jq`) it emits a small projectable record
// (`{"deleted": true, "id": <id>}`, plus any extra keys) so that
// `... remove <id> --jq '.id'` and `... --jq '.deleted'` project something
// instead of the empty stream that a delete endpoint with a 204/empty body
// otherwise produces. In human mode it prints the friendly confirmation
// line via printHumanConfirmation. Mirrors the rest of the CLI where every
// other verb's `--jq` projects from a response body.
func printDeletion(id string, extra map[string]interface{}, humanFormat string, humanArgs ...interface{}) error {
if flagJSON || flagQuiet || flagJQ != "" {
result := map[string]interface{}{"deleted": true, "id": id}
for k, v := range extra {
result[k] = v
}
return printer.PrintOne(result, nil)
}
printHumanConfirmation(humanFormat, humanArgs...)
return nil
}
12 changes: 12 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ func newRootCmd() *cobra.Command {
}
}

// 0b. Warn when --lang is combined with --name. `name` on
// collections / screens / media is a flat, non-translated
// label — there's no per-locale slot for it, so --lang is
// silently ignored and the single shared label is
// overwritten. An agent doing a translation pass who treats
// --name like --title clobbers the original instead of adding
// a locale; point them at --title. Stderr only, so
// machine-readable stdout is unaffected.
if flagLang != "" && cmd.Flags().Changed("name") {
fmt.Fprintf(os.Stderr, "warning: --name is a flat, non-translatable field — --lang does not apply to it (it overwrites the single shared label, not a per-locale value). Use --title for translatable per-locale text.\n")
}

// 1. Initialise printer.
printer = &output.Printer{JSON: flagJSON, Quiet: flagQuiet, Raw: flagRaw}

Expand Down
68 changes: 49 additions & 19 deletions internal/cli/screens.go
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,7 @@ func newSectionsGetCmd() *cobra.Command {

func newSectionsAddCmd() *cobra.Command {
var sectionType, title, subtitle, body, description, textPosition, mapType, displayAddress string
var mediaItemID, position, exposableContent int
var mediaItemID, position, exposableContent, quizQuestionID, quizID int
var lat, lng float64
var directionsEnabled, collapsable, autoPlay, zoomable, fullScreen, slideshowEnabled bool

Expand All @@ -615,21 +615,30 @@ func newSectionsAddCmd() *cobra.Command {
stqry screens sections add 42 --type single_media --media-item-id 99 --auto-play

# Add a titled gallery section in French
stqry screens sections add 42 --type media_group --title "Galerie" --lang fr`,
stqry screens sections add 42 --type media_group --title "Galerie" --lang fr

# Embed a quiz question on the screen (the QuizQuestion must already exist)
stqry screens sections add 42 --type quiz_question --quiz-question-id 10930

# Show the quiz's final score/result section
stqry screens sections add 42 --type quiz_score --quiz-id 2056`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateSectionType(sectionType); err != nil {
return err
}
// quiz_question and quiz_score are in the StorySection schema oneOf
// (so they stay in validSectionTypes), but the server rejects a
// create call for either with 422 'Subtype Question/Quiz can't be
// blank' because both require a nested Subtype payload that the CLI
// exposes no flags for. Fail client-side with a clear message
// instead of letting the user discover the gap through a confusing
// server-side error.
if sectionType == "quiz_question" || sectionType == "quiz_score" {
return fmt.Errorf("section type %q requires a Subtype (question / quiz) payload that the CLI cannot author yet. Build the quiz screen in STQRY Builder for now and use the CLI for everything else", sectionType)
// quiz_question / quiz_score are Subtype-backed sections: the
// server resolves the subtype from the type and assigns its
// subtype fields (quiz_question → quiz_question_id [+ optional
// zoomable]; quiz_score → quiz_id). Both ids are server-required
// (validates presence). Catch the missing id client-side so the
// user gets a clear pointer instead of a bare `422 Subtype …
// must exist`.
if sectionType == "quiz_question" && !cmd.Flags().Changed("quiz-question-id") {
return fmt.Errorf("--type quiz_question requires --quiz-question-id <id> (the QuizQuestion to embed; create one with `stqry quizzes questions add`)")
}
if sectionType == "quiz_score" && !cmd.Flags().Changed("quiz-id") {
return fmt.Errorf("--type quiz_score requires --quiz-id <id> (the Quiz whose score to show; create one with `stqry quizzes create`)")
}
if err := validateEnum("text-position", textPosition, validTextPositions); err != nil {
return err
Expand Down Expand Up @@ -700,6 +709,10 @@ func newSectionsAddCmd() *cobra.Command {
fields["full_screen"] = fullScreen
case "slideshow-enabled":
fields["slideshow_enabled"] = slideshowEnabled
case "quiz-question-id":
fields["quiz_question_id"] = quizQuestionID
case "quiz-id":
fields["quiz_id"] = quizID
}
})
// Location sections need a map_type; default to single_location if lat/lng given without one.
Expand Down Expand Up @@ -730,13 +743,15 @@ func newSectionsAddCmd() *cobra.Command {
cmd.Flags().StringVar(&mapType, "map-type", "", "Map type for location sections (single_location, multiple_locations; default: single_location)")
cmd.Flags().StringVar(&displayAddress, "display-address", "", "Display address for location sections")
cmd.Flags().BoolVar(&directionsEnabled, "directions-enabled", false, "Enable directions for location sections")
cmd.Flags().IntVar(&position, "position", 0, "Position within the screen (0-based; omit to append to the end). Note: this writes a literal position value — the server does NOT shift existing siblings to make room. If you insert into a screen that already has sections, you may still need a follow-up 'sections reorder' to get the order you want (server-side).")
cmd.Flags().IntVar(&position, "position", 0, "Position within the screen (0-based; omit to append to the end). The server inserts at this slot and shifts existing siblings down, so the new section lands exactly here and positions stay contiguous — no follow-up 'sections reorder' needed.")
cmd.Flags().BoolVar(&collapsable, "collapsable", false, "Render the section's body collapsed with a \"read more\" toggle. Pair with --exposable-content to control how much of the body is shown before the toggle. Available on any section that has translatable body content (text, single_media, etc.).")
cmd.Flags().IntVar(&exposableContent, "exposable-content", 0, "Percentage of the body content (0–100) shown before the \"read more\" toggle when --collapsable is set. Only meaningful with --collapsable; the server stores it on every section but it has no visible effect when collapsable is false.")
cmd.Flags().BoolVar(&autoPlay, "auto-play", false, "Start the attached audio/video automatically on screen entry (single_media sections). For media_group sections, set auto-play per item via 'stqry screens sections media add/update --auto-play'.")
cmd.Flags().BoolVar(&zoomable, "zoomable", false, "Let viewers pinch/tap-to-zoom on the section's image (single_media / media_group / quiz_question sections). No effect on text or non-image sections.")
cmd.Flags().BoolVar(&fullScreen, "full-screen", false, "Render the section's media full-screen (media_group sections). No effect on other section types.")
cmd.Flags().BoolVar(&slideshowEnabled, "slideshow-enabled", false, "Auto-advance through the media items as a slideshow (media_group sections). No effect on other section types.")
cmd.Flags().IntVar(&quizQuestionID, "quiz-question-id", 0, "QuizQuestion ID to embed (required for --type quiz_question). Create the question with 'stqry quizzes questions add'.")
cmd.Flags().IntVar(&quizID, "quiz-id", 0, "Quiz ID whose final score to show (required for --type quiz_score). Create the quiz with 'stqry quizzes create'.")

return cmd
}
Expand All @@ -745,7 +760,7 @@ func newSectionsAddCmd() *cobra.Command {

func newSectionsUpdateCmd() *cobra.Command {
var screenID, title, subtitle, body, description, textPosition, layout string
var mediaItemID, exposableContent int
var mediaItemID, exposableContent, quizQuestionID, quizID int
var collapsable, autoPlay, zoomable, fullScreen, slideshowEnabled bool

cmd := &cobra.Command{
Expand Down Expand Up @@ -814,6 +829,10 @@ func newSectionsUpdateCmd() *cobra.Command {
fields["full_screen"] = fullScreen
case "slideshow-enabled":
fields["slideshow_enabled"] = slideshowEnabled
case "quiz-question-id":
fields["quiz_question_id"] = quizQuestionID
case "quiz-id":
fields["quiz_id"] = quizID
}
})

Expand All @@ -839,6 +858,8 @@ func newSectionsUpdateCmd() *cobra.Command {
cmd.Flags().BoolVar(&zoomable, "zoomable", false, "Let viewers pinch/tap-to-zoom on the section's image (single_media / media_group / quiz_question sections).")
cmd.Flags().BoolVar(&fullScreen, "full-screen", false, "Render the section's media full-screen (media_group sections).")
cmd.Flags().BoolVar(&slideshowEnabled, "slideshow-enabled", false, "Auto-advance through the media items as a slideshow (media_group sections).")
cmd.Flags().IntVar(&quizQuestionID, "quiz-question-id", 0, "Point a quiz_question section at a different QuizQuestion ID.")
cmd.Flags().IntVar(&quizID, "quiz-id", 0, "Point a quiz_score section at a different Quiz ID.")

return cmd
}
Expand Down Expand Up @@ -878,11 +899,11 @@ func newSectionsRemoveCmd() *cobra.Command {
return err
}
if lang != "" {
printHumanConfirmation("Removed %s translations from section %s.", lang, args[0])
} else {
printHumanConfirmation("Removed section %s from screen %s.", args[0], screenID)
return printDeletion(args[0], map[string]interface{}{"language": lang, "screen_id": screenID},
"Removed %s translations from section %s.", lang, args[0])
}
return nil
return printDeletion(args[0], map[string]interface{}{"screen_id": screenID},
"Removed section %s from screen %s.", args[0], screenID)
},
}

Expand Down Expand Up @@ -982,11 +1003,20 @@ func newSubItemListCmd(cmdName, apiPath string) *cobra.Command {
var screenID, sectionID string

cmd := &cobra.Command{
Use: "list",
Use: "list [screen-id]",
Short: fmt.Sprintf("List %s", cmdName),
// Accept the screen id either positionally (`... media list 42
// --section-id 99`) or via --screen-id. The sibling `sections add`
// verb takes the screen id positionally, so agents naturally try the
// same shape here; previously the positional value was silently
// ignored and the command errored with "--screen-id is required".
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if screenID == "" && len(args) == 1 {
screenID = args[0]
}
if screenID == "" {
return fmt.Errorf("--screen-id is required")
return fmt.Errorf("screen id is required: pass it positionally (`... %s list <screen-id> --section-id <id>`) or via --screen-id", cmdName)
}
if sectionID == "" {
return fmt.Errorf("--section-id is required")
Expand Down
147 changes: 134 additions & 13 deletions internal/cli/screens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1283,15 +1283,90 @@ func TestSectionsAddCmdBodyUnderLang(t *testing.T) {
}
}

// TestScreensSectionsAddQuizGuard asserts that `sections add --type
// quiz_question|quiz_score` fails client-side with a clear message
// instead of letting the server return a confusing 422
// 'Subtype Question must exist' / 'Subtype Quiz can't be blank'
// Both types stay in `validSectionTypes`
// because they're real StorySection variants in the schema — but the
// CLI can't author the nested Subtype payload yet, so the create
// would be doomed end-to-end.
func TestScreensSectionsAddQuizGuard(t *testing.T) {
// TestScreensCreateLoopTitlesNotDropped pins a regression: a loop of separate
// `screens create --title X-N` invocations must each persist its own title —
// no first-write drop, no shift-by-one across the batch. Each iteration builds
// a fresh root command (a fresh process/client in real use), and we assert the
// POST body's title carries the right value every time, including the first.
func TestScreensCreateLoopTitlesNotDropped(t *testing.T) {
var titles []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&b)
if tm, ok := b["title"].(map[string]interface{}); ok {
if v, ok := tm["en"].(string); ok {
titles = append(titles, v)
} else {
titles = append(titles, "<missing-en>")
}
} else {
titles = append(titles, "<no-title>")
}
w.WriteHeader(201)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"screen": map[string]interface{}{"id": len(titles)}})
}))
defer server.Close()
setupTestHome(t, server.URL)

want := []string{"Stop-1", "Stop-2", "Stop-3", "Stop-4"}
for _, title := range want {
cmd := newRootCmd()
cmd.SetArgs([]string{"--lang", "en", "screens", "create", "--type", "story", "--name", title, "--title", title, "--quiet"})
cmd.SetErr(io.Discard)
cmd.SetOut(io.Discard)
if err := cmd.Execute(); err != nil {
t.Fatalf("create %q: %v", title, err)
}
}
if len(titles) != len(want) {
t.Fatalf("expected %d creates, got %d (%v)", len(want), len(titles), titles)
}
for i, w := range want {
if titles[i] != w {
t.Errorf("create %d: title sent = %q, want %q (full: %v)", i, titles[i], w, titles)
}
}
}

// TestSectionsRemoveProjectsWithJQ asserts that `sections remove <id> --jq
// '.id'` projects the deleted section id (synthesized {deleted:true,id}),
// rather than the empty stream the 204 delete body would otherwise yield.
func TestSectionsRemoveProjectsWithJQ(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("unexpected method %s", r.Method)
}
w.WriteHeader(204)
}))
defer server.Close()
setupTestHome(t, server.URL)

origStdout := os.Stdout
rp, wp, _ := os.Pipe()
os.Stdout = wp

cmd := newRootCmd()
cmd.SetArgs([]string{"screens", "sections", "remove", "99", "--screen-id=42", "--jq", ".id"})
cmd.SetErr(io.Discard)
err := cmd.Execute()
wp.Close()
os.Stdout = origStdout
out, _ := io.ReadAll(rp)
rp.Close()
if err != nil {
t.Fatalf("Execute: %v", err)
}
if got := strings.TrimSpace(string(out)); got != `"99"` {
t.Errorf("expected --jq '.id' to project \"99\", got %q", got)
}
}

// TestScreensSectionsAddQuizRequiresID asserts that `sections add --type
// quiz_question|quiz_score` without the matching subtype id fails
// client-side with a clear pointer, before any HTTP request. The server
// requires quiz_question_id / quiz_id (the subtype validates presence), so
// catching it locally beats a confusing `422 Subtype … must exist`.
func TestScreensSectionsAddQuizRequiresID(t *testing.T) {
hits := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
Expand All @@ -1301,25 +1376,71 @@ func TestScreensSectionsAddQuizGuard(t *testing.T) {
defer server.Close()
setupTestHome(t, server.URL)

for _, ty := range []string{"quiz_question", "quiz_score"} {
cases := map[string]string{
"quiz_question": "--quiz-question-id",
"quiz_score": "--quiz-id",
}
for ty, wantFlag := range cases {
cmd := newRootCmd()
cmd.SetArgs([]string{"screens", "sections", "add", "42", "--type", ty})
cmd.SetErr(io.Discard)
cmd.SetOut(io.Discard)
err := cmd.Execute()
if err == nil {
t.Errorf("%s: expected client-side guard to error, got nil", ty)
t.Errorf("%s: expected missing-id error, got nil", ty)
continue
}
if !strings.Contains(err.Error(), "Subtype") {
t.Errorf("%s: error didn't mention Subtype payload; got: %v", ty, err)
if !strings.Contains(err.Error(), wantFlag) {
t.Errorf("%s: error didn't mention %s; got: %v", ty, wantFlag, err)
}
}
if hits != 0 {
t.Errorf("guard should fire before any HTTP request, but server saw %d hits", hits)
}
}

// TestScreensSectionsAddQuizForwardsIDs asserts that, when the subtype id is
// provided, the create body carries the right subtype fields:
// quiz_question → quiz_question_id (+ zoomable when set); quiz_score → quiz_id.
func TestScreensSectionsAddQuizForwardsIDs(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
w.WriteHeader(201)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"story_section": map[string]interface{}{"id": 1}})
}))
defer server.Close()
setupTestHome(t, server.URL)

// quiz_question with zoomable
cmd := newRootCmd()
cmd.SetArgs([]string{"screens", "sections", "add", "42", "--type", "quiz_question", "--quiz-question-id", "10930", "--zoomable"})
cmd.SetErr(io.Discard)
cmd.SetOut(io.Discard)
if err := cmd.Execute(); err != nil {
t.Fatalf("quiz_question create failed: %v", err)
}
if got, ok := body["quiz_question_id"].(float64); !ok || int(got) != 10930 {
t.Errorf("quiz_question_id not forwarded; body=%v", body)
}
if z, ok := body["zoomable"].(bool); !ok || !z {
t.Errorf("zoomable not forwarded; body=%v", body)
}

// quiz_score
body = nil
cmd = newRootCmd()
cmd.SetArgs([]string{"screens", "sections", "add", "42", "--type", "quiz_score", "--quiz-id", "2056"})
cmd.SetErr(io.Discard)
cmd.SetOut(io.Discard)
if err := cmd.Execute(); err != nil {
t.Fatalf("quiz_score create failed: %v", err)
}
if got, ok := body["quiz_id"].(float64); !ok || int(got) != 2056 {
t.Errorf("quiz_id not forwarded; body=%v", body)
}
}

// TestSectionsAddCmdMediaToggles asserts that --zoomable / --full-screen /
// --slideshow-enabled forward as the matching snake_case booleans in the
// create body. All three are spec-defined writable fields on the media-
Expand Down
Loading
Loading