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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ for structured JSON suitable for piping into `jq` or another tool. List
commands assign each result a stable index (`1`, `2`, `3`, …) you can use
in follow-up commands like `show`, `edit`, `complete`, and `cancel`.

`--fields` limits a JSON listing to the keys you name, in that order, which
keeps output small enough for an agent to read whole:
`things anytime -j --fields uuid,title,projectTitle,deadline`. It works on
the task listings, `search` and `projects`, and needs `--json`. An unknown
name fails with the list of valid ones. See the
[command reference](https://things.rlew.io/commands/).

`--json` also implies non-interactive: the CLI never prompts, so a reference
matching several tasks returns an error listing the candidates instead of
opening the picker, and `complete`/`cancel` on a project declines instead of
Expand All @@ -67,7 +74,7 @@ how a project completes under `--json`.
A failing command prints a single JSON object to stdout and exits non-zero, so
a consumer parsing stdout gets a structured failure either way. `error` is a
stable token — `ambiguous task`, `not found`, `not a task`, `not a project`, `trashed`,
`stale list cache`, `already closed`, `misfiled`, `import refused`,
`stale list cache`, `already closed`, `misfiled`, `unknown field`, `import refused`,
`import partially applied`, or `error` for anything else —
and `message` carries the same text plain-text mode prints.

Expand Down
1 change: 1 addition & 0 deletions cmd/things/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var helpVars = kong.Vars{
"add_deadline_help": `Deadline: a YYYY-MM-DD date or an English phrase such as "next friday". ` + deadlineNoKeyword,
"edit_when_help": "Schedule: " + whenValues + ", RFC3339, or empty to clear.",
"edit_deadline_help": `Deadline: a YYYY-MM-DD date, an English phrase such as "next friday", or empty to clear. ` + deadlineNoKeyword,
"fields_help": fieldsHelp,
}

// parserOptions is the one place the kong parser is configured. main and the
Expand Down
26 changes: 20 additions & 6 deletions cmd/things/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ import (
//
// Error is a stable token: "ambiguous task", "not found", "not a task",
// "not a project", "trashed", "stale list cache", "already closed",
// "empty reference", "misfiled", "blank-title", "import refused", "import
// partially applied", or "error" for a failure with no structure worth
// naming. "blank-title" is spelt as the import reason for the same
// refusal, so a caller matches one string for both.
// "empty reference", "misfiled", "blank-title", "unknown field", "import
// refused", "import partially applied", or "error" for a failure with no
// structure worth naming. "blank-title" is spelt as the import reason for
// the same refusal, so a caller matches one string for both.
// Message is the same text the plain-text path prints, for a human reading
// the JSON.
type jsonErrorPayload struct {
Expand Down Expand Up @@ -57,6 +57,10 @@ type jsonErrorPayload struct {
// in Items: "too-many-items" for a payload over the size Things takes
// without asking.
Reason string `json:"reason,omitempty"`
// Unknown and Valid are given on an "unknown field" error: the --fields
// names the rows do not have, and every name they do, in record order.
Unknown []string `json:"unknown,omitempty"`
Valid []string `json:"valid,omitempty"`
}

// jsonErrorItem is one item of a batch failure. An import acts on many items
Expand Down Expand Up @@ -497,8 +501,9 @@ type payloader interface {
func errorPayload(err error) jsonErrorPayload {
payload := jsonErrorPayload{Error: "error", Message: err.Error()}

// internal/db knows nothing of the wire format, so its errors are
// mapped here rather than by a method of theirs.
// internal/db and internal/output know nothing of the error wire
// format, so their errors are mapped here rather than by a method of
// theirs.
var ambig *db.AmbiguousTaskError
if errors.As(err, &ambig) {
payload.Error = "ambiguous task"
Expand All @@ -519,6 +524,15 @@ func errorPayload(err error) jsonErrorPayload {
return payload
}

var unknownField *output.UnknownFieldError
if errors.As(err, &unknownField) {
payload.Error = "unknown field"
payload.Kind = unknownField.Kind
payload.Unknown = unknownField.Unknown
payload.Valid = unknownField.Valid
return payload
}

var p payloader
if errors.As(err, &p) {
p.fillPayload(&payload)
Expand Down
28 changes: 28 additions & 0 deletions cmd/things/fields.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package main

import (
"fmt"

"github.com/ryanlewis/things-cli/internal/output"
)

// fieldsHelp is the --fields help text, shared by every listing that takes it.
const fieldsHelp = "With --json, print only these comma-separated keys of each row, in this order, e.g. uuid,title,deadline. A row leaves out a key it would leave out of the full record. Names are the JSON keys as printed; an unknown one is rejected with the list of valid ones."

// listingFields reads a listing's --fields into the keys to print, or nil
// for the full record. It runs before the database is opened: a flag the
// command cannot honour is a mistake in the invocation, not something to
// report after the work is done.
//
// --fields only shapes JSON. Plain output has its own columns, and quietly
// ignoring the flag there would leave a script believing it had asked for
// something it got.
func listingFields(d *Deps, raw *string, kind string, valid []string) ([]string, error) {
if raw == nil {
return nil, nil
}
if !d.JSON {
return nil, fmt.Errorf("--fields selects keys of the JSON output, so it needs --json (-j)")
}
return output.ParseFields(*raw, kind, valid)
}
170 changes: 170 additions & 0 deletions cmd/things/fields_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package main

import (
"encoding/json"
"errors"
"slices"
"strings"
"testing"

"github.com/alecthomas/kong"

"github.com/ryanlewis/things-cli/internal/output"
)

// fieldKeys parses a --fields listing and returns each row's keys in the
// order printed.
func fieldKeys(t *testing.T, out string) [][]string {
t.Helper()
var rows []json.RawMessage
if err := json.Unmarshal([]byte(out), &rows); err != nil {
t.Fatalf("stdout is not a JSON array: %v\n%s", err, out)
}
keys := make([][]string, len(rows))
for i, r := range rows {
dec := json.NewDecoder(strings.NewReader(string(r)))
if _, err := dec.Token(); err != nil { // {
t.Fatal(err)
}
for dec.More() {
tok, err := dec.Token()
if err != nil {
t.Fatal(err)
}
keys[i] = append(keys[i], tok.(string))
var skip json.RawMessage
if err := dec.Decode(&skip); err != nil {
t.Fatal(err)
}
}
}
return keys
}

func TestRunListingFields(t *testing.T) {
cases := map[string]struct {
args []string
want []string
}{
"bare default view": {[]string{"--json", "--fields", "title,uuid"}, []string{"title", "uuid"}},
"named view": {[]string{"--json", "anytime", "--fields", "uuid,projectTitle"}, []string{"uuid", "projectTitle"}},
"list with filter": {[]string{"-j", "list", "--project", "Chores", "--fields", "title"}, []string{"title"}},
"search": {[]string{"-j", "search", "milk", "--fields", "uuid, title,uuid"}, []string{"uuid", "title"}},
"projects": {[]string{"-j", "projects", "--fields", "openCount,title"}, []string{"openCount", "title"}},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
stdout, _, err := runStreams(t, seedFullDB(t), c.args...)
if err != nil {
t.Fatalf("run: %v", err)
}
rows := fieldKeys(t, stdout)
if len(rows) == 0 {
t.Fatalf("no rows listed:\n%s", stdout)
}
for i, keys := range rows {
// A row may lack an omitempty key, never carry an extra one
// or change the order.
if len(keys) == 0 || !isSubsequence(keys, c.want) {
t.Errorf("row %d keys = %v, want %v", i, keys, c.want)
}
}
})
}
}

func isSubsequence(got, of []string) bool {
j := 0
for _, k := range got {
for j < len(of) && of[j] != k {
j++
}
if j == len(of) {
return false
}
j++
}
return true
}

// A row whose project is set prints projectTitle where it was asked for.
func TestRunListingFieldsCarryValues(t *testing.T) {
stdout, _, err := runStreams(t, seedFullDB(t), "--json", "today", "--fields", "projectTitle,title")
if err != nil {
t.Fatalf("run: %v", err)
}
want := "[\n {\n \"projectTitle\": \"Chores\",\n \"title\": \"Buy milk\"\n }\n]\n"
if stdout != want {
t.Errorf("stdout =\n%s\nwant\n%s", stdout, want)
}
}

// Plain output has its own columns, so --fields there is a mistake to report,
// not a flag to ignore.
func TestRunFieldsNeedsJSON(t *testing.T) {
for _, args := range [][]string{
{"--fields", "uuid"},
{"anytime", "--fields", "uuid"},
{"projects", "--fields", "uuid"},
{"search", "milk", "--fields", "uuid"},
} {
stdout, _, err := runStreams(t, seedFullDB(t), args...)
if err == nil || !strings.Contains(err.Error(), "needs --json") {
t.Errorf("%v: err = %v, want the needs --json refusal", args, err)
}
if stdout != "" {
t.Errorf("%v: stdout = %q, want nothing", args, stdout)
}
}
}

func TestRunFieldsRejectsUnknownName(t *testing.T) {
cases := map[string]struct {
args []string
kind string
valid []string
}{
"task listing": {[]string{"-j", "anytime", "--fields", "uuid,project"}, "task", output.TaskFields},
"projects": {[]string{"-j", "projects", "--fields", "uuid,notes"}, "project", output.ProjectFields},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
stdout, _, err := runStreams(t, seedFullDB(t), c.args...)
if err == nil {
t.Fatal("unknown field accepted")
}
if stdout != "" {
t.Errorf("stdout = %q, want nothing before the error", stdout)
}
p := errorPayload(err)
if p.Error != "unknown field" || p.Kind != c.kind {
t.Errorf("payload error/kind = %q/%q", p.Error, p.Kind)
}
if len(p.Unknown) != 1 || !slices.Equal(p.Valid, c.valid) {
t.Errorf("payload unknown/valid = %v/%v", p.Unknown, p.Valid)
}
})
}
}

// The names are checked before the database is opened, so a bad list is
// reported as such even where there is no database to read.
func TestRunFieldsCheckedBeforeDatabase(t *testing.T) {
_, _, err := runStreams(t, nil, "--db", "/nonexistent/main.sqlite", "-j", "--fields", "bogus")
var ufe *output.UnknownFieldError
if !errors.As(err, &ufe) {
t.Errorf("err = %v, want the unknown-field error", err)
}
}

// show prints the full record; --fields is a listing flag only.
func TestShowTakesNoFields(t *testing.T) {
var cli CLI
parser, err := kong.New(&cli, parserOptions(nil)...)
if err != nil {
t.Fatalf("kong.New: %v", err)
}
if _, err := parser.Parse([]string{"-j", "show", "task-1", "--fields", "uuid"}); err == nil {
t.Error("show accepted --fields")
}
}
35 changes: 33 additions & 2 deletions cmd/things/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,15 @@ type ListCmd struct {
On string `help:"Only tasks scheduled on YYYY-MM-DD (or RFC3339). On 'deadlines', filters by deadline; on 'upcoming', an undated task is matched by its deadline. Mutually exclusive with --from/--to."`
From string `help:"Only tasks scheduled on or after YYYY-MM-DD (or RFC3339). On 'deadlines', filters by deadline; on 'upcoming', an undated task is matched by its deadline."`
To string `help:"Only tasks scheduled on or before YYYY-MM-DD (or RFC3339). On 'deadlines', filters by deadline; on 'upcoming', an undated task is matched by its deadline."`

Fields *string `help:"${fields_help}" placeholder:"KEYS"`
}

func (c *ListCmd) Run(kctx *kong.Context, d *Deps) error {
fields, err := listingFields(d, c.Fields, "task", output.TaskFields)
if err != nil {
return err
}
database, err := d.Database()
if err != nil {
return err
Expand Down Expand Up @@ -109,7 +115,12 @@ func (c *ListCmd) Run(kctx *kong.Context, d *Deps) error {
if filtered && view != db.ViewProject {
viewLabel = view
}
if err := output.PrintViewTaskList(d.Stdout, tasks, d.JSON, view, viewLabel); err != nil {
if fields != nil {
err = output.PrintTaskFields(d.Stdout, tasks, fields)
} else {
err = output.PrintViewTaskList(d.Stdout, tasks, d.JSON, view, viewLabel)
}
if err != nil {
return err
}
noteEmptyRepeatingProject(d, database, view, project, len(tasks))
Expand Down Expand Up @@ -237,9 +248,15 @@ type ProjectsCmd struct {
Area string `help:"Filter by area name or UUID." short:"a"`
Completed bool `help:"Include completed projects." default:"false"`
OpenOnly bool `help:"Leave out the projects closed but not yet logged, which are listed by default as Things still shows them: under the app's default Daily logging, the ones closed today. Ignored with --completed. --open-only=false overrides open_only in the config file."`

Fields *string `help:"${fields_help}" placeholder:"KEYS"`
}

func (c *ProjectsCmd) Run(d *Deps) error {
fields, err := listingFields(d, c.Fields, "project", output.ProjectFields)
if err != nil {
return err
}
database, err := d.Database()
if err != nil {
return err
Expand All @@ -248,6 +265,9 @@ func (c *ProjectsCmd) Run(d *Deps) error {
if err != nil {
return err
}
if fields != nil {
return output.PrintProjectFields(d.Stdout, projects, fields)
}
return output.PrintProjects(d.Stdout, projects, d.JSON)
}

Expand Down Expand Up @@ -322,9 +342,15 @@ func printItem(d *Deps, database *db.DB, task *model.Task) error {

type SearchCmd struct {
Query string `arg:"" required:"" help:"Search query."`

Fields *string `help:"${fields_help}" placeholder:"KEYS"`
}

func (c *SearchCmd) Run(d *Deps) error {
fields, err := listingFields(d, c.Fields, "task", output.TaskFields)
if err != nil {
return err
}
database, err := d.Database()
if err != nil {
return err
Expand All @@ -339,7 +365,12 @@ func (c *SearchCmd) Run(d *Deps) error {
// they share PrintTaskList's path (and hint) rather than the bare Print
// ListCmd used to diverge to; an empty view label prints identically to
// the old output.Print(tasks) call did.
if err := output.PrintTaskList(d.Stdout, tasks, d.JSON, ""); err != nil {
if fields != nil {
err = output.PrintTaskFields(d.Stdout, tasks, fields)
} else {
err = output.PrintTaskList(d.Stdout, tasks, d.JSON, "")
}
if err != nil {
return err
}
return printAgentHint(d, len(tasks))
Expand Down
9 changes: 6 additions & 3 deletions docs/content/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,12 @@ for them a to-do sharing the title does not get in the way.

The tokens are `ambiguous task`, `not found`, `not a task`, `not a project`,
`trashed`, `stale list cache`, `already closed`, `empty reference`, `misfiled`,
`blank-title`, `import refused`, `import partially applied`, and `error` for
everything else. `empty reference` is an empty or all-space `<task>`
argument, refused before any lookup. `blank-title` is an `add`, `project
`blank-title`, `unknown field`, `import refused`, `import partially applied`,
and `error` for everything else. `empty reference` is an empty or all-space
`<task>` argument, refused before any lookup. `unknown field` is a `--fields`
list naming a key the listing's rows do not have, refused before anything is
read: `unknown` lists the names it did not know and `valid` every name it
takes, so read `valid` and retry. `blank-title` is an `add`, `project
add`, or an `edit` or `project edit` `--title`, whose title is empty or only
whitespace: nothing is sent, as `import` refuses the same title with reason
`blank-title`.
Expand Down
Loading
Loading