diff --git a/cmd/cmsctl/README.md b/cmd/cmsctl/README.md index ae2880a..292add7 100644 --- a/cmd/cmsctl/README.md +++ b/cmd/cmsctl/README.md @@ -57,6 +57,23 @@ backoffice proxy. Finder administration always uses `/catalog/admin`. The CLI intentionally has no separate finder-admin URL: the backoffice is the writeradmin boundary. +## Todos and suggestions + +List, filter, and complete improvement notes filed via the Suggest button or +API: + +```bash +cmsctl todos list # all todos in triage sort (open first) +cmsctl todos list --open # open / uncompleted only (--done=false) +cmsctl todos list --done # completed only (--done=true) +cmsctl todos list --prio high --q "checkout" # filter by priority and search text +cmsctl todos done NODE_ID # mark completed (sets data.done = true) +cmsctl todos reopen NODE_ID # reopen (sets data.done = false) +cmsctl todos get NODE_ID # get todo node +cmsctl todos create --note "Fix cart layout" --prio high --location "Commerce ยท Cart" +cmsctl todos delete NODE_ID --yes +``` + ## CMS ```bash diff --git a/cmd/cmsctl/cmsctl_test.go b/cmd/cmsctl/cmsctl_test.go index 7a30d4b..a6d8e11 100644 --- a/cmd/cmsctl/cmsctl_test.go +++ b/cmd/cmsctl/cmsctl_test.go @@ -293,3 +293,97 @@ func TestFinderAdminAlwaysUsesBackofficeBaseURL(t *testing.T) { t.Fatalf("requests = %v %v", methods, paths) } } + +func TestTodosListFiltersAndDoneCommands(t *testing.T) { + var requests []struct { + Method string + Path string + Query string + Body string + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests = append(requests, struct { + Method string + Path string + Query string + Body string + }{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.RawQuery, + Body: string(body), + }) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPatch { + _, _ = io.WriteString(w, `{"id":"todo-1","dataType":"todo","data":{"done":true}}`) + return + } + if r.Method == http.MethodPost { + _, _ = io.WriteString(w, `{"id":"todo-created","dataType":"todo"}`) + return + } + _, _ = io.WriteString(w, `{"items":[],"total":0}`) + })) + defer server.Close() + + client := newClient(Config{BaseURL: server.URL}, "token", "", time.Second) + var output bytes.Buffer + + // 1. List with --open + if err := runTodos(context.Background(), client, []string{"list", "--open", "--prio", "high", "--q", "cart"}, strings.NewReader(""), &output); err != nil { + t.Fatalf("todos list --open: %v", err) + } + if len(requests) != 1 || requests[0].Path != "/api/todos" { + t.Fatalf("unexpected request: %+v", requests) + } + if !strings.Contains(requests[0].Query, "done=false") || !strings.Contains(requests[0].Query, "prio=high") || !strings.Contains(requests[0].Query, "q=cart") { + t.Fatalf("unexpected query string: %q", requests[0].Query) + } + + // 2. List with --done=true + requests = nil + if err := runTodos(context.Background(), client, []string{"list", "--done=true"}, strings.NewReader(""), &output); err != nil { + t.Fatalf("todos list --done=true: %v", err) + } + if len(requests) != 1 || !strings.Contains(requests[0].Query, "done=true") { + t.Fatalf("expected done=true in query: %q", requests[0].Query) + } + + // 3. Mark done + requests = nil + if err := runTodos(context.Background(), client, []string{"done", "todo-123"}, strings.NewReader(""), &output); err != nil { + t.Fatalf("todos done: %v", err) + } + if len(requests) != 1 || requests[0].Method != http.MethodPatch || requests[0].Path != "/api/nodes/todo-123" { + t.Fatalf("unexpected mark done request: %+v", requests) + } + if !strings.Contains(requests[0].Body, `"done":true`) { + t.Fatalf("expected done:true in patch body: %q", requests[0].Body) + } + + // 4. Reopen + requests = nil + if err := runTodos(context.Background(), client, []string{"reopen", "todo-123"}, strings.NewReader(""), &output); err != nil { + t.Fatalf("todos reopen: %v", err) + } + if len(requests) != 1 || requests[0].Method != http.MethodPatch || requests[0].Path != "/api/nodes/todo-123" { + t.Fatalf("unexpected reopen request: %+v", requests) + } + if !strings.Contains(requests[0].Body, `"done":false`) { + t.Fatalf("expected done:false in reopen body: %q", requests[0].Body) + } + + // 5. Create + requests = nil + if err := runTodos(context.Background(), client, []string{"create", "--note", "Fix button", "--prio", "high", "--location", "Admin"}, strings.NewReader(""), &output); err != nil { + t.Fatalf("todos create: %v", err) + } + if len(requests) != 1 || requests[0].Method != http.MethodPost || requests[0].Path != "/api/nodes" { + t.Fatalf("unexpected create request: %+v", requests) + } + if !strings.Contains(requests[0].Body, `"dataType":"todo"`) || !strings.Contains(requests[0].Body, `"note":"Fix button"`) { + t.Fatalf("unexpected create body: %q", requests[0].Body) + } +} + diff --git a/cmd/cmsctl/commands.go b/cmd/cmsctl/commands.go index db48a46..4c9b605 100644 --- a/cmd/cmsctl/commands.go +++ b/cmd/cmsctl/commands.go @@ -19,6 +19,8 @@ func runCMS(ctx context.Context, client *Client, args []string, stdin io.Reader, return nil } switch args[0] { + case "todos", "todo": + return runTodos(ctx, client, args[1:], stdin, stdout) case "nodes": return runCMSNodes(ctx, client, args[1:], stdin, stdout) case "search": diff --git a/cmd/cmsctl/main.go b/cmd/cmsctl/main.go index ba8d45a..1b50fe1 100644 --- a/cmd/cmsctl/main.go +++ b/cmd/cmsctl/main.go @@ -25,6 +25,13 @@ Usage: cmsctl auth token [--raw] Print the persisted token cmsctl auth logout Remove the persisted token + cmsctl todos [list] [flags] List todos (?done, prio, q, limit) + cmsctl todos done Mark a todo as completed + cmsctl todos reopen Mark a todo as open / not done + cmsctl todos get Get one todo node + cmsctl todos create --note '...' [flags] Create a new todo + cmsctl todos delete --yes Delete a todo node + cmsctl cms nodes list [flags] List CMS nodes cmsctl cms nodes get Get one node cmsctl cms nodes create [flags] Create a node @@ -137,6 +144,9 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) erro switch rest[0] { case "auth": return runAuth(ctx, cfgPath, &cfg, rest[1:], stdout, stderr, global.timeout) + case "todos", "todo": + client := newClient(cfg, global.token, global.apiKey, global.timeout) + return runTodos(ctx, client, rest[1:], stdin, stdout) case "cms": client := newClient(cfg, global.token, global.apiKey, global.timeout) return runCMS(ctx, client, rest[1:], stdin, stdout) diff --git a/cmd/cmsctl/todos.go b/cmd/cmsctl/todos.go new file mode 100644 index 0000000..62a3f37 --- /dev/null +++ b/cmd/cmsctl/todos.go @@ -0,0 +1,193 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/url" + "strconv" + "strings" +) + +func runTodos(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { + if len(args) == 0 { + return runTodosList(ctx, client, nil, stdout) + } + if args[0] == "help" || args[0] == "--help" || args[0] == "-h" { + _, _ = io.WriteString(stdout, "Usage: cmsctl todos [list] [--done=true|false|open|done|all] [--open] [--prio low|medium|high] [--q query] [--limit N] [--offset N]\n cmsctl todos done \n cmsctl todos reopen \n cmsctl todos get \n cmsctl todos create --note '...' [--prio low|medium|high] [--location '...'] [--url '...']\n cmsctl todos delete --yes\n") + return nil + } + + // If the first argument is a flag (e.g. `cmsctl todos --open`), treat as `list` + if strings.HasPrefix(args[0], "-") { + return runTodosList(ctx, client, args, stdout) + } + + switch args[0] { + case "list": + return runTodosList(ctx, client, args[1:], stdout) + case "done", "mark-done": + return runTodosSetDone(ctx, client, args[1:], true, stdout) + case "reopen", "undone", "open": + return runTodosSetDone(ctx, client, args[1:], false, stdout) + case "get": + id, err := requireID(args[1:], "cmsctl todos get requires a todo id") + if err != nil { + return err + } + body, err := client.get(ctx, client.cmsURL("/nodes/"+escapePathSegment(id))) + if err != nil { + return err + } + return writeRaw(stdout, body) + case "create": + return runTodosCreate(ctx, client, args[1:], stdin, stdout) + case "delete": + return cmsDeleteNode(ctx, client, args[1:], stdout) + default: + return fmt.Errorf("unknown todos command %q (use --help)", args[0]) + } +} + +type doneFlag struct { + set bool + value string +} + +func (d *doneFlag) String() string { + return d.value +} + +func (d *doneFlag) Set(s string) error { + d.set = true + d.value = strings.TrimSpace(strings.ToLower(s)) + return nil +} + +func (d *doneFlag) IsBoolFlag() bool { + return true +} + +func runTodosList(ctx context.Context, client *Client, args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("todos list", flag.ContinueOnError) + var done doneFlag + fs.Var(&done, "done", "filter by completion status: true|false, open|done, or all") + openOnly := fs.Bool("open", false, "filter to open / uncompleted todos (shorthand for --done=false)") + prio := fs.String("prio", "", "priority filter: low, medium, or high") + q := fs.String("q", "", "search substring over note/location/url") + limit := fs.Int("limit", 100, "page size; use 0 for all") + offset := fs.Int("offset", 0, "page offset") + fs.SetOutput(io.Discard) + if err := fs.Parse(args); err != nil { + return err + } + + query := url.Values{ + "limit": {strconv.Itoa(*limit)}, + "offset": {strconv.Itoa(*offset)}, + } + if *prio != "" { + query.Set("prio", strings.ToLower(*prio)) + } + if *q != "" { + query.Set("q", *q) + } + + if *openOnly { + query.Set("done", "false") + } else if done.set { + switch done.value { + case "true", "t", "1", "done", "yes": + query.Set("done", "true") + case "false", "f", "0", "open", "no": + query.Set("done", "false") + case "all", "": + // omit parameter to fetch all + default: + return fmt.Errorf("invalid --done value %q: use true, false, open, done, or all", done.value) + } + } + + body, err := client.get(ctx, client.cmsURL("/todos")+"?"+query.Encode()) + if err != nil { + return err + } + return writeRaw(stdout, body) +} + +func runTodosSetDone(ctx context.Context, client *Client, args []string, done bool, stdout io.Writer) error { + id, err := requireID(args, "cmsctl todos done requires a todo id") + if err != nil { + return err + } + patch := map[string]any{ + "data": map[string]any{ + "done": done, + }, + } + body, err := json.Marshal(patch) + if err != nil { + return err + } + response, err := client.patchBody(ctx, client.cmsURL("/nodes/"+escapePathSegment(id)), body, "application/json") + if err != nil { + return err + } + return writeRaw(stdout, response) +} + +func runTodosCreate(ctx context.Context, client *Client, args []string, stdin io.Reader, stdout io.Writer) error { + fs := flag.NewFlagSet("todos create", flag.ContinueOnError) + note := fs.String("note", "", "improvement note / description") + prio := fs.String("prio", "medium", "priority: low, medium, or high") + done := fs.Bool("done", false, "initial completion status") + location := fs.String("location", "", "admin view title where suggestion was filed") + rawURL := fs.String("url", "", "page URL where suggestion was filed") + tags := fs.String("tags", "", "comma-separated tags") + input := fs.String("input", "", "complete request JSON file or - for stdin") + fs.SetOutput(io.Discard) + if err := fs.Parse(args); err != nil { + return err + } + + body, err := readInput(stdin, *input, fs.Args()) + if err != nil { + return fmt.Errorf("read todo input: %w", err) + } + if len(body) == 0 { + if strings.TrimSpace(*note) == "" { + return errors.New("todos create requires --note") + } + data := map[string]any{ + "note": strings.TrimSpace(*note), + "prio": *prio, + "done": *done, + } + if *location != "" { + data["location"] = *location + } + if *rawURL != "" { + data["url"] = *rawURL + } + node := map[string]any{ + "dataType": "todo", + "data": data, + "tags": splitCSV(*tags), + } + body, err = json.Marshal(node) + if err != nil { + return err + } + } + if !json.Valid(body) { + return errors.New("create todo input is not valid JSON") + } + response, err := client.postBody(ctx, client.cmsURL("/nodes"), body, "application/json") + if err != nil { + return err + } + return writeRaw(stdout, response) +}