From 104bb31fae13aea2b4519f7abb0afab658c45692 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sat, 10 Oct 2026 15:01:57 +0100 Subject: [PATCH 1/2] feat(output): add --fields to limit JSON listings to named keys --fields on the task listings, search and projects prints only the named JSON keys of each row, in the order given. Names are the keys as printed, read off the model's json tags; an unknown name fails with an "unknown field" error listing the valid ones. It needs --json, and show keeps the full record. --- README.md | 9 +- cmd/things/config.go | 1 + cmd/things/errors.go | 26 +++- cmd/things/fields.go | 28 ++++ cmd/things/fields_test.go | 170 +++++++++++++++++++++++ cmd/things/list.go | 35 ++++- docs/content/commands.md | 36 +++++ internal/output/fields.go | 158 +++++++++++++++++++++ internal/output/fields_test.go | 246 +++++++++++++++++++++++++++++++++ internal/skill/SKILL.md | 7 +- 10 files changed, 704 insertions(+), 12 deletions(-) create mode 100644 cmd/things/fields.go create mode 100644 cmd/things/fields_test.go create mode 100644 internal/output/fields.go create mode 100644 internal/output/fields_test.go diff --git a/README.md b/README.md index 0b5f3706..9281d58e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/cmd/things/config.go b/cmd/things/config.go index ae6b188c..76ae8554 100644 --- a/cmd/things/config.go +++ b/cmd/things/config.go @@ -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 diff --git a/cmd/things/errors.go b/cmd/things/errors.go index 1861245a..5ad4e589 100644 --- a/cmd/things/errors.go +++ b/cmd/things/errors.go @@ -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 { @@ -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 @@ -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" @@ -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) diff --git a/cmd/things/fields.go b/cmd/things/fields.go new file mode 100644 index 00000000..e8a5d513 --- /dev/null +++ b/cmd/things/fields.go @@ -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) +} diff --git a/cmd/things/fields_test.go b/cmd/things/fields_test.go new file mode 100644 index 00000000..5af8e8c9 --- /dev/null +++ b/cmd/things/fields_test.go @@ -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") + } +} diff --git a/cmd/things/list.go b/cmd/things/list.go index 70462aa4..34b9553f 100644 --- a/cmd/things/list.go +++ b/cmd/things/list.go @@ -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 @@ -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)) @@ -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 @@ -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) } @@ -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 @@ -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)) diff --git a/docs/content/commands.md b/docs/content/commands.md index 8df5f0c0..235d70f4 100644 --- a/docs/content/commands.md +++ b/docs/content/commands.md @@ -49,6 +49,41 @@ takes: that payload is Things' own JSON URL scheme, which spells a task than the CLI's, because it is passed through untouched. Do not copy `.type` from a listing into an import item. +### Choosing fields with `--fields` + +A JSON listing prints every field of every row, and notes alone can be +half of it. `--fields` cuts each row down to the keys you name, in the +order you name them: + +```sh +things anytime -a "Personal Projects" -j --fields uuid,title,projectTitle,deadline,tags +things projects -j --fields uuid,title,openCount +``` + +It works on every task listing (the views, `list` with filters, and +`search`) and on `things projects`. `things show` always prints the full +record, and `areas` and `tags` rows are short enough not to need it. + +- The names are the JSON keys exactly as the full output prints them, case + included: `projectTitle`, not `project`. There are no aliases. +- A task listing takes the task keys: `uuid`, `title`, `notes`, `type`, + `status`, `start`, `startBucket`, `startDate`, `reminderTime`, + `deadline`, `stopDate`, `creationDate`, `trashed`, `projectUUID`, + `projectTitle`, `projectTrashed`, `areaUUID`, `areaTitle`, `headingUUID`, + `headingTitle`, `tags`, `index`, `todayIndex`, `repeating`, + `checklistProgress`. `things projects` takes the project keys: `uuid`, + `title`, `status`, `start`, `startBucket`, `startDate`, `deadline`, + `areaUUID`, `areaTitle`, `tags`, `taskCount`, `openCount`. +- A key a row would leave out of the full record, such as `deadline` on a + task with none, is left out here too. The values are unchanged. +- Spaces around names are ignored, and a name given twice is printed once, + where it first appears. +- An unknown name fails the whole command before anything is read, with + the `unknown field` error: `unknown` lists the names it did not know and + `valid` lists every name it takes. +- `--fields` needs `--json`; without it the command fails rather than + ignore the flag. Plain output is unchanged. + ## Listing ```sh @@ -480,6 +515,7 @@ with the commands that act on the item. See ```sh things search "milk" things search "release" --json +things search "release" --json --fields uuid,title ``` The query matches titles and notes literally and case-insensitively, so diff --git a/internal/output/fields.go b/internal/output/fields.go new file mode 100644 index 00000000..7aa52e24 --- /dev/null +++ b/internal/output/fields.go @@ -0,0 +1,158 @@ +package output + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "reflect" + "slices" + "strings" + + "github.com/ryanlewis/things-cli/internal/model" +) + +// TaskFields and ProjectFields are the names --fields takes on a task listing +// and on `things projects`: the JSON keys a row of that kind can carry, in +// the order the full record prints them. They are read off the struct tags, +// so a field added to the model is selectable without a second list to keep +// in step. +var ( + TaskFields = jsonKeys(reflect.TypeFor[model.Task]()) + ProjectFields = jsonKeys(reflect.TypeFor[model.Project]()) +) + +// jsonKeys lists the keys encoding/json writes for struct type t. The model +// types have no embedded or untagged exported fields, so the tag name is +// the whole rule. +func jsonKeys(t reflect.Type) []string { + var keys []string + for f := range t.Fields() { + if !f.IsExported() { + continue + } + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + if name == "-" { + continue + } + if name == "" { + name = f.Name + } + keys = append(keys, name) + } + return keys +} + +// UnknownFieldError is a --fields list naming a key the rows do not have. +// Kind is "task" or "project", the kind of row the listing prints. +type UnknownFieldError struct { + Kind string + Unknown []string + Valid []string +} + +func (e *UnknownFieldError) Error() string { + quoted := make([]string, len(e.Unknown)) + for i, u := range e.Unknown { + quoted[i] = fmt.Sprintf("%q", u) + } + noun := "field" + if len(e.Unknown) > 1 { + noun = "fields" + } + return fmt.Sprintf("--fields: unknown %s %s for a %s listing; valid fields: %s", + noun, strings.Join(quoted, ", "), e.Kind, strings.Join(e.Valid, ", ")) +} + +// ParseFields reads a --fields value: a comma-separated list of keys from +// valid. Each name is trimmed and a repeat is dropped, keeping the first, so +// the result is the order the caller asked for. A name not in valid is an +// *UnknownFieldError naming every such name; a list with no names at all is +// refused too, since printing empty objects is never what was meant. +func ParseFields(raw, kind string, valid []string) ([]string, error) { + var fields, unknown []string + for name := range strings.SplitSeq(raw, ",") { + name = strings.TrimSpace(name) + if name == "" || slices.Contains(fields, name) || slices.Contains(unknown, name) { + continue + } + if !slices.Contains(valid, name) { + unknown = append(unknown, name) + continue + } + fields = append(fields, name) + } + if len(unknown) > 0 { + return nil, &UnknownFieldError{Kind: kind, Unknown: unknown, Valid: valid} + } + if len(fields) == 0 { + return nil, fmt.Errorf("--fields names no fields; valid fields: %s", strings.Join(valid, ", ")) + } + return fields, nil +} + +// PrintTaskFields prints tasks as the JSON array --json does, each row cut +// down to fields in that order. A field the full record would omit on a row +// (an empty omitempty value) is omitted here too. +func PrintTaskFields(w io.Writer, tasks []model.Task, fields []string) error { + return printFields(w, tasks, fields) +} + +// PrintProjectFields is PrintTaskFields for a project listing. +func PrintProjectFields(w io.Writer, projects []model.Project, fields []string) error { + return printFields(w, projects, fields) +} + +// printFields projects each row's marshalled form rather than the struct, so +// the values are encoded exactly as the full record encodes them. +func printFields[T any](w io.Writer, rows []T, fields []string) error { + out := make([]fieldRow, len(rows)) + for i := range rows { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + // As in PrintJSON: the values are read as text, so "R&D" stays as + // written. The outer encoder keeps that setting when it re-indents. + enc.SetEscapeHTML(false) + if err := enc.Encode(&rows[i]); err != nil { + return err + } + var full map[string]json.RawMessage + if err := json.Unmarshal(buf.Bytes(), &full); err != nil { + return err + } + out[i] = fieldRow{keys: fields, values: full} + } + return PrintJSON(w, out) +} + +// fieldRow is one projected row: keys in the order asked for, each written +// only when the full record carries it. +type fieldRow struct { + keys []string + values map[string]json.RawMessage +} + +func (r fieldRow) MarshalJSON() ([]byte, error) { + var b bytes.Buffer + b.WriteByte('{') + first := true + for _, k := range r.keys { + v, ok := r.values[k] + if !ok { + continue + } + if !first { + b.WriteByte(',') + } + first = false + key, err := json.Marshal(k) + if err != nil { + return nil, err + } + b.Write(key) + b.WriteByte(':') + b.Write(v) + } + b.WriteByte('}') + return b.Bytes(), nil +} diff --git a/internal/output/fields_test.go b/internal/output/fields_test.go new file mode 100644 index 00000000..9e0b962c --- /dev/null +++ b/internal/output/fields_test.go @@ -0,0 +1,246 @@ +package output + +import ( + "bytes" + "encoding/json" + "errors" + "slices" + "strings" + "testing" + "time" + + "github.com/ryanlewis/things-cli/internal/model" +) + +// The valid names are the keys the full record prints, so a field added to +// the model is selectable and one hidden from JSON is not. +func TestFieldNamesFollowJSONTags(t *testing.T) { + for _, want := range []string{"uuid", "title", "notes", "projectTitle", "checklistProgress"} { + if !slices.Contains(TaskFields, want) { + t.Errorf("TaskFields lacks %q: %v", want, TaskFields) + } + } + for _, name := range []string{"-", "ModificationDate", "modificationDate"} { + if slices.Contains(TaskFields, name) { + t.Errorf("TaskFields has %q, which the record never prints", name) + } + } + if TaskFields[0] != "uuid" || TaskFields[1] != "title" { + t.Errorf("TaskFields should follow record order, got %v", TaskFields[:2]) + } + for _, want := range []string{"uuid", "taskCount", "openCount"} { + if !slices.Contains(ProjectFields, want) { + t.Errorf("ProjectFields lacks %q: %v", want, ProjectFields) + } + } + if slices.Contains(ProjectFields, "notes") { + t.Errorf("ProjectFields has notes, which a project row does not carry") + } +} + +// Every name a printed row carries must be selectable, or --fields would +// reject a key the user can see in the full output. +func TestFieldNamesCoverFullRecord(t *testing.T) { + stop := mustTime(t, "2026-01-02T03:04:05Z") + sd := model.ThingsDateFromTime(stop) + rt := "09:00" + task := model.Task{ + UUID: "u", Title: "t", Notes: "n", StartDate: &sd, ReminderTime: &rt, Deadline: &sd, + StopDate: &stop, CreationDate: &stop, ProjectUUID: "p", ProjectTitle: "P", ProjectTrashed: true, + AreaUUID: "a", AreaTitle: "A", HeadingUUID: "h", HeadingTitle: "H", Tags: []string{"x"}, + Repeating: true, ChecklistProgress: &model.ChecklistProgress{Total: 1}, + } + project := model.Project{UUID: "u", Title: "t", StartDate: &sd, Deadline: &sd, AreaUUID: "a", AreaTitle: "A", Tags: []string{"x"}} + for _, c := range []struct { + row any + valid []string + }{{task, TaskFields}, {project, ProjectFields}} { + b, err := json.Marshal(c.row) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + if len(m) != len(c.valid) { + t.Errorf("record has %d keys, valid list %d: %v", len(m), len(c.valid), c.valid) + } + for k := range m { + if !slices.Contains(c.valid, k) { + t.Errorf("record key %q is not a valid field", k) + } + } + } +} + +func TestParseFields(t *testing.T) { + cases := []struct { + raw string + want []string + }{ + {"uuid,title", []string{"uuid", "title"}}, + {"title,uuid", []string{"title", "uuid"}}, + {" title , uuid ", []string{"title", "uuid"}}, + {"title,title,uuid,title", []string{"title", "uuid"}}, + {"uuid,,title,", []string{"uuid", "title"}}, + } + for _, c := range cases { + got, err := ParseFields(c.raw, "task", TaskFields) + if err != nil { + t.Errorf("ParseFields(%q): %v", c.raw, err) + continue + } + if !slices.Equal(got, c.want) { + t.Errorf("ParseFields(%q) = %v, want %v", c.raw, got, c.want) + } + } +} + +func TestParseFieldsRejectsUnknown(t *testing.T) { + _, err := ParseFields("uuid,project,bogus,project", "task", TaskFields) + var ufe *UnknownFieldError + if !errors.As(err, &ufe) { + t.Fatalf("err = %v, want *UnknownFieldError", err) + } + if !slices.Equal(ufe.Unknown, []string{"project", "bogus"}) { + t.Errorf("Unknown = %v", ufe.Unknown) + } + if ufe.Kind != "task" || !slices.Equal(ufe.Valid, TaskFields) { + t.Errorf("Kind/Valid = %q %v", ufe.Kind, ufe.Valid) + } + msg := err.Error() + for _, want := range []string{`"project"`, `"bogus"`, "task listing", "projectTitle"} { + if !strings.Contains(msg, want) { + t.Errorf("message %q lacks %q", msg, want) + } + } +} + +// Names are exact keys: case matters, as it does to jq. +func TestParseFieldsIsCaseSensitive(t *testing.T) { + if _, err := ParseFields("UUID", "task", TaskFields); err == nil { + t.Error("UUID accepted; want only the key as printed") + } +} + +func TestParseFieldsRejectsEmpty(t *testing.T) { + for _, raw := range []string{"", " ", ",", " , "} { + if _, err := ParseFields(raw, "task", TaskFields); err == nil { + t.Errorf("ParseFields(%q) accepted a list naming no fields", raw) + } + } +} + +func projectedRows(t *testing.T, out string) []map[string]json.RawMessage { + t.Helper() + var rows []map[string]json.RawMessage + if err := json.Unmarshal([]byte(out), &rows); err != nil { + t.Fatalf("output is not a JSON array of objects: %v\n%s", err, out) + } + return rows +} + +func TestPrintTaskFieldsKeepsRequestedOrder(t *testing.T) { + var buf bytes.Buffer + tasks := []model.Task{{UUID: "u1", Title: "One", Notes: "long notes"}} + if err := PrintTaskFields(&buf, tasks, []string{"title", "uuid"}); err != nil { + t.Fatal(err) + } + want := "[\n {\n \"title\": \"One\",\n \"uuid\": \"u1\"\n }\n]\n" + if buf.String() != want { + t.Errorf("got\n%s\nwant\n%s", buf.String(), want) + } +} + +// A requested key the full record leaves out on a row is left out here too, +// so a row reads the same whichever way it was asked for. +func TestPrintTaskFieldsOmitsWhatTheRecordOmits(t *testing.T) { + d := model.ThingsDateFromTime(mustTime(t, "2026-10-12T00:00:00Z")) + tasks := []model.Task{ + {UUID: "u1", Title: "Dated", Deadline: &d, Tags: []string{"a"}}, + {UUID: "u2", Title: "Bare"}, + } + var buf bytes.Buffer + if err := PrintTaskFields(&buf, tasks, []string{"uuid", "deadline", "tags", "trashed"}); err != nil { + t.Fatal(err) + } + rows := projectedRows(t, buf.String()) + if len(rows) != 2 { + t.Fatalf("rows = %d", len(rows)) + } + var tags bytes.Buffer + if err := json.Compact(&tags, rows[0]["tags"]); err != nil { + t.Fatal(err) + } + if string(rows[0]["deadline"]) != `"2026-10-12"` || tags.String() != `["a"]` { + t.Errorf("row 0 = %v", rows[0]) + } + if _, ok := rows[1]["deadline"]; ok { + t.Errorf("row 1 carries a deadline it does not have: %v", rows[1]) + } + if _, ok := rows[1]["tags"]; ok { + t.Errorf("row 1 carries tags it does not have: %v", rows[1]) + } + // trashed is not omitempty, so false is printed as the record prints it. + if string(rows[1]["trashed"]) != "false" { + t.Errorf("row 1 trashed = %s, want false", rows[1]["trashed"]) + } +} + +// Every value is the full record's value byte for byte: the projection cuts +// keys, it does not re-encode. +func TestPrintTaskFieldsValuesMatchFullRecord(t *testing.T) { + d := model.ThingsDateFromTime(mustTime(t, "2026-10-12T00:00:00Z")) + stop := mustTime(t, "2026-10-01T08:30:00Z") + tasks := []model.Task{{ + UUID: "u1", Title: "R&D ", Type: model.TypeProject, Status: model.StatusCompleted, + Start: model.StartSomeday, StartDate: &d, StopDate: &stop, ProjectTitle: "P", + ChecklistProgress: &model.ChecklistProgress{Total: 3, Open: 1}, + }} + var full, cut bytes.Buffer + if err := PrintTaskList(&full, tasks, true, ""); err != nil { + t.Fatal(err) + } + if err := PrintTaskFields(&cut, tasks, TaskFields); err != nil { + t.Fatal(err) + } + // All fields in record order is the full record. + if cut.String() != full.String() { + t.Errorf("all fields differs from the full record:\n%s\nvs\n%s", cut.String(), full.String()) + } + if !strings.Contains(cut.String(), `"R&D "`) { + t.Errorf("HTML characters were escaped: %s", cut.String()) + } +} + +func TestPrintFieldsEmptyListIsArray(t *testing.T) { + var buf bytes.Buffer + if err := PrintTaskFields(&buf, nil, []string{"uuid"}); err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(buf.String()); got != "[]" { + t.Errorf("got %q, want []", got) + } +} + +func TestPrintProjectFields(t *testing.T) { + projects := []model.Project{{UUID: "p1", Title: "Chores", AreaTitle: "Home", TaskCount: 4, OpenCount: 2}} + var buf bytes.Buffer + if err := PrintProjectFields(&buf, projects, []string{"title", "openCount"}); err != nil { + t.Fatal(err) + } + want := "[\n {\n \"title\": \"Chores\",\n \"openCount\": 2\n }\n]\n" + if buf.String() != want { + t.Errorf("got\n%s\nwant\n%s", buf.String(), want) + } +} + +func mustTime(t *testing.T, s string) time.Time { + t.Helper() + v, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatal(err) + } + return v +} diff --git a/internal/skill/SKILL.md b/internal/skill/SKILL.md index 262dddf9..f5349ad6 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -47,6 +47,7 @@ Every command that reads or writes Things accepts `--json` / `-j`; `version`, `c - `someday` is the deferred things not filed under a project: Someday projects and unparented Someday tasks. A task inside a project stays inside it however it is deferred, so it is not a `someday` row even when its project is in Someday too. Reach those with `things --project "Name"` — `things someday --project "Name"` is rejected, because no `someday` row has a parent project for it to match. - `logbook` is everything closed, not just everything finished: cancelled items sit beside completed ones, as they do in the app's Logbook. Split them on `"status"` — `"completed"` or `"cancelled"`; plain output prints `[x]` and `[~]`. Filter with `jq '.[] | select(.status=="completed")'` when you mean finished rather than closed. - `things projects` also reports `taskCount` and `openCount`. `taskCount` is every untrashed task in the project; `openCount` is the ones still open. The difference is the ones that are no longer open, which means completed or cancelled. Tasks filed under a project heading count towards both; the heading rows themselves never do, and neither do trashed tasks or checklist items. Both numbers are Things' own bookkeeping, read straight from the database rather than recounted by the CLI. +- **Cut big listings with `--fields`.** `things anytime -a "Area" -j --fields uuid,title,projectTitle,deadline,tags` prints only those keys of each row, in that order; notes alone can be half a full listing. It works on the task listings, `search` and `projects` (not `show`, which is always the full record). Names are the JSON keys exactly as printed (`projectTitle`, not `project`); a row leaves out a key the full record would leave out. An unknown name fails before anything is read with `{"error": "unknown field", "kind": "task", "unknown": [...], "valid": [...]}`, so read `valid` and retry. It needs `--json`. - Human output is styled and column-aligned; colour auto-disables when piping or under `NO_COLOR`. `--color=always|never` overrides. JSON is unaffected. **A failure under `--json` prints one JSON object to stdout and exits non-zero.** Branch on the exit status and read the failure off stdout — not stderr. On success the read commands print their result there. Of the write commands, `add`, `project add`, `edit`, `project edit`, `complete` and `cancel` print the item (rule 3), `tag add` reports what it created and what it skipped, and the rest print nothing. `error` is a stable token; `message` is the human text. @@ -176,7 +177,7 @@ The user may have a TOML file at `~/.config/things-cli/config.toml` (or `$XDG_CO Global flags, valid on every command: `-j/--json`, `--color=auto|always|never`, `--db PATH`, `--config PATH`, `--no-verify`, `--verify-timeout DURATION` (how long a write's read-back waits, default `5s`; must be above zero — use `--no-verify` to skip it), `--no-hints`, `-v/--version`. ``` -things list [view] [--project P] [--area A] [--tag T] [--on D | --from D --to D] [--open-only] +things list [view] [--project P] [--area A] [--tag T] [--on D | --from D --to D] [--open-only] [--fields KEYS] # views: today, inbox, upcoming, anytime, someday, repeating, logbook, trash, deadlines # shortcut: `things today`, `things inbox`, etc. # bare `things` is today — but --project/--area/--tag alone list every open @@ -293,7 +294,7 @@ things list [view] [--project P] [--area A] [--tag T] [--on D | --from D --to D] # page does, and lists that project's row instead. things show [--agent] # detail; --agent prints a Markdown brief (see below) -things projects [-a|--area A] [--completed] [--open-only] +things projects [-a|--area A] [--completed] [--open-only] [--fields KEYS] # open projects plus those closed and not yet logged (as the app's list # still shows them; "status" tells them apart), unless --open-only; # --completed lists every closed project too. @@ -301,7 +302,7 @@ things projects [-a|--area A] [--completed] [--open-only] # plus taskCount/openCount in JSON things areas things tags -things search # titles and notes, matched literally; a lookup, not a view +things search [--fields KEYS] # titles and notes, matched literally; a lookup, not a view # skips trashed rows and tasks of a project in the trash, as the app does things tag add ... # create tags; existing names are skipped From e973565562a7a85c604d425daf31be64ad689ba2 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sat, 10 Oct 2026 15:07:43 +0100 Subject: [PATCH 2/2] fix(output): encode a --fields listing once and list unknown field in agents.md --- docs/content/agents.md | 9 ++++++--- internal/output/fields.go | 34 ++++++++++++++++++---------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/content/agents.md b/docs/content/agents.md index 5f686ae4..a6218735 100644 --- a/docs/content/agents.md +++ b/docs/content/agents.md @@ -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 `` -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 +`` 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`. diff --git a/internal/output/fields.go b/internal/output/fields.go index 7aa52e24..d1431d0c 100644 --- a/internal/output/fields.go +++ b/internal/output/fields.go @@ -83,7 +83,9 @@ func ParseFields(raw, kind string, valid []string) ([]string, error) { fields = append(fields, name) } if len(unknown) > 0 { - return nil, &UnknownFieldError{Kind: kind, Unknown: unknown, Valid: valid} + // A copy, so a caller holding the error cannot rewrite the shared + // TaskFields or ProjectFields it was given. + return nil, &UnknownFieldError{Kind: kind, Unknown: unknown, Valid: slices.Clone(valid)} } if len(fields) == 0 { return nil, fmt.Errorf("--fields names no fields; valid fields: %s", strings.Join(valid, ", ")) @@ -106,21 +108,21 @@ func PrintProjectFields(w io.Writer, projects []model.Project, fields []string) // printFields projects each row's marshalled form rather than the struct, so // the values are encoded exactly as the full record encodes them. func printFields[T any](w io.Writer, rows []T, fields []string) error { - out := make([]fieldRow, len(rows)) - for i := range rows { - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - // As in PrintJSON: the values are read as text, so "R&D" stays as - // written. The outer encoder keeps that setting when it re-indents. - enc.SetEscapeHTML(false) - if err := enc.Encode(&rows[i]); err != nil { - return err - } - var full map[string]json.RawMessage - if err := json.Unmarshal(buf.Bytes(), &full); err != nil { - return err - } - out[i] = fieldRow{keys: fields, values: full} + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + // As in PrintJSON: the values are read as text, so "R&D" stays as + // written. The outer encoder keeps that setting when it re-indents. + enc.SetEscapeHTML(false) + if err := enc.Encode(rows); err != nil { + return err + } + var full []map[string]json.RawMessage + if err := json.Unmarshal(buf.Bytes(), &full); err != nil { + return err + } + out := make([]fieldRow, len(full)) + for i, values := range full { + out[i] = fieldRow{keys: fields, values: values} } return PrintJSON(w, out) }