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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,14 @@ 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
A JSON listing prints a default set of keys per row, enough to pick an
item and act on its `uuid`; notes, the parent uuids and Things' own
bookkeeping are left out. `--fields` names the keys you want instead, in
that order: `things anytime -j --fields uuid,title,projectTitle,deadline`.
`--fields all` prints every key, as `--json` did by default in v0.10.0 and
earlier, and `things show <uuid> -j` prints one item's full record. 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
Expand Down
14 changes: 9 additions & 5 deletions cmd/things/fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,22 @@ import (
)

// 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."
const fieldsHelp = "With --json, print only these comma-separated keys of each row, in this order, e.g. uuid,title,deadline, or 'all' for the full record. Without it, --json prints a default set that leaves out notes, the parent uuids and Things' bookkeeping. 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.
// for the full record or plain output. A JSON listing without --fields gets
// defaults, the default set for its kind. 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) {
func listingFields(d *Deps, raw *string, kind string, valid, defaults []string) ([]string, error) {
if raw == nil {
if d.JSON {
return defaults, nil
}
return nil, nil
}
if !d.JSON {
Expand Down
103 changes: 103 additions & 0 deletions cmd/things/fields_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (

"github.com/alecthomas/kong"

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

Expand Down Expand Up @@ -157,6 +160,106 @@ func TestRunFieldsCheckedBeforeDatabase(t *testing.T) {
}
}

// Without --fields a JSON listing prints its kind's default set, and
// --fields all the full record.
func TestRunListingDefaultFields(t *testing.T) {
cases := map[string]struct {
args []string
defaults []string
full []string
}{
"bare default view": {[]string{"--json"}, output.TaskDefaultFields, []string{"index", "todayIndex", "trashed", "startBucket"}},
"named view": {[]string{"--json", "anytime"}, output.TaskDefaultFields, []string{"index", "todayIndex", "trashed", "startBucket"}},
"list with filter": {[]string{"-j", "list", "--project", "Chores"}, output.TaskDefaultFields, []string{"index", "todayIndex", "trashed", "startBucket"}},
"search": {[]string{"-j", "search", "milk"}, output.TaskDefaultFields, []string{"index", "todayIndex", "trashed", "startBucket"}},
"projects": {[]string{"-j", "projects"}, output.ProjectDefaultFields, []string{"startBucket"}},
}
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 {
if !isSubsequence(keys, c.defaults) {
t.Errorf("row %d keys = %v, want a cut of %v", i, keys, c.defaults)
}
// Every row has these, so a default set cut to nothing fails.
if !slices.Contains(keys, "uuid") || !slices.Contains(keys, "title") || !slices.Contains(keys, "status") {
t.Errorf("row %d keys = %v, want uuid, title and status", i, keys)
}
}

stdout, _, err = runStreams(t, seedFullDB(t), append(c.args, "--fields", "all")...)
if err != nil {
t.Fatalf("run --fields all: %v", err)
}
for i, keys := range fieldKeys(t, stdout) {
for _, k := range c.full {
if !slices.Contains(keys, k) {
t.Errorf("--fields all row %d lacks %q: %v", i, k, keys)
}
}
}
})
}
}

// today -j tells the app's This Evening rows apart without --fields, as the
// app's own Today does.
func TestRunTodayDefaultCarriesStartBucket(t *testing.T) {
sqlDB := dbtest.NewSQL(t)
fx := dbtest.NewFixture(t, sqlDB)
today := int64(model.ThingsDateFromTime(testNow))
fx.Todo("day", "Day task", 0, dbtest.AnytimeOn(today))
fx.Todo("eve", "Evening task", 1, dbtest.Evening(today))
stdout, _, err := runStreams(t, db.NewFromSQL(sqlDB), "-j", "today")
if err != nil {
t.Fatalf("run: %v", err)
}
var rows []struct {
UUID string `json:"uuid"`
StartBucket *int `json:"startBucket"`
}
if err := json.Unmarshal([]byte(stdout), &rows); err != nil {
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout)
}
got := map[string]int{}
for _, r := range rows {
if r.StartBucket == nil {
t.Fatalf("row %s has no startBucket:\n%s", r.UUID, stdout)
}
got[r.UUID] = *r.StartBucket
}
if len(got) != 2 || got["day"] != 0 || got["eve"] != 1 {
t.Errorf("startBucket by uuid = %v, want day 0 and eve 1", got)
}
}

// An empty list gets the unknown-field payload, with the valid names and none
// unknown, so an agent can read the names off it either way.
func TestRunFieldsEmptyListsValidNames(t *testing.T) {
_, _, err := runStreams(t, seedFullDB(t), "-j", "anytime", "--fields", " , ")
p := errorPayload(err)
if p.Error != "unknown field" || p.Kind != "task" || len(p.Unknown) != 0 || !slices.Equal(p.Valid, output.TaskFields) {
t.Errorf("payload = %+v", p)
}
}

func TestRunFieldsAllTakesNoOtherNames(t *testing.T) {
stdout, _, err := runStreams(t, seedFullDB(t), "-j", "projects", "--fields", "all,uuid")
if err == nil || !strings.Contains(err.Error(), "full record") {
t.Errorf("err = %v, want all-with-names refusal", err)
}
if stdout != "" {
t.Errorf("stdout = %q, want nothing", stdout)
}
}

// show prints the full record; --fields is a listing flag only.
func TestShowTakesNoFields(t *testing.T) {
var cli CLI
Expand Down
6 changes: 3 additions & 3 deletions cmd/things/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type ListCmd struct {
}

func (c *ListCmd) Run(kctx *kong.Context, d *Deps) error {
fields, err := listingFields(d, c.Fields, "task", output.TaskFields)
fields, err := listingFields(d, c.Fields, "task", output.TaskFields, output.TaskDefaultFields)
if err != nil {
return err
}
Expand Down Expand Up @@ -253,7 +253,7 @@ type ProjectsCmd struct {
}

func (c *ProjectsCmd) Run(d *Deps) error {
fields, err := listingFields(d, c.Fields, "project", output.ProjectFields)
fields, err := listingFields(d, c.Fields, "project", output.ProjectFields, output.ProjectDefaultFields)
if err != nil {
return err
}
Expand Down Expand Up @@ -347,7 +347,7 @@ type SearchCmd struct {
}

func (c *SearchCmd) Run(d *Deps) error {
fields, err := listingFields(d, c.Fields, "task", output.TaskFields)
fields, err := listingFields(d, c.Fields, "task", output.TaskFields, output.TaskDefaultFields)
if err != nil {
return err
}
Expand Down
23 changes: 18 additions & 5 deletions docs/content/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,10 +266,10 @@ nothing on success. Their errors are still JSON.
everything else. `today` lists the evening items after all the others,
as the app does.
- **Projects carry scheduling too.** `things projects` reports `start`,
`startBucket`, `startDate` and `deadline` under the same names and
encodings a task uses, so a scheduled project reads the same way
without a per-project `show`. `startDate` and `deadline` are omitted
when unset.
`startDate` and `deadline` (and `startBucket` when `--fields` is `all`
or names it) under the same names and encodings a task uses, so a
scheduled project reads the same way without a per-project `show`.
`startDate` and `deadline` are omitted when unset.
- **Projects carry their progress too.** `things projects` reports
`taskCount`, every untrashed task in the project, and `openCount`, the
ones still open. The difference is the ones no longer open, which means
Expand All @@ -284,6 +284,18 @@ nothing on success. Their errors are still JSON.
`--when 2026-10-09@09:00` sets one, reports `reminderTime` as a local
`"HH:MM"` clock time on its `startDate`. A task without one leaves the
field out.
- **A listing prints a default set of keys.** Without `--fields`, a task
listing, `search` and `things projects` print the keys needed to pick an
item and act on it, and leave out `notes`, the parent uuids
(`projectUUID`, `areaUUID`, `headingUUID`), `creationDate` and Things'
bookkeeping (`index`, `todayIndex`, `trashed`, and `startBucket` on a
`things projects` row). Read an item's notes with
`things show <uuid> --json`, or pass `--fields all` for every key of
every row. `--fields` also names the keys you want, in order. **This changed:** in v0.10.0 and earlier
`--json` printed every key, so a script reading one of those keys off a
listing needs `--fields all`. The
[command reference](/commands/#choosing-fields-with---fields) lists the
default keys.

```console
$ things show milk --json; echo "exit=$?"
Expand Down Expand Up @@ -313,7 +325,8 @@ 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
takes, so read `valid` and retry. An empty `--fields` gets the same token,
with `valid` and no `unknown`. `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
31 changes: 25 additions & 6 deletions docs/content/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,31 @@ 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:
A JSON listing prints a default set of keys for each row: the ones needed
to pick an item and act on it. Notes, the parent uuids and Things' own
bookkeeping are left out, since notes alone can be half of a full listing.
`--fields` names the keys you want instead, in the order you want them, and
`--fields all` prints every key:

```sh
things anytime -a "Personal Projects" -j --fields uuid,title,projectTitle,deadline,tags
things projects -j --fields uuid,title,openCount
things anytime -j --fields all
things show <uuid> -j # one item's full record, notes and checklist included
```

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.

- Without `--fields`, a task row carries these keys: `uuid`, `title`,
`type`, `status`, `start`, `startBucket`, `startDate`, `reminderTime`,
`deadline`, `stopDate`, `projectTitle`, `projectTrashed`, `areaTitle`,
`headingTitle`, `tags`, `repeating`, `checklistProgress`. A project row
from `things projects` carries these keys: `uuid`, `title`, `status`, `start`, `startDate`,
`deadline`, `areaTitle`, `tags`, `taskCount`, `openCount`.
- `--fields all` prints the full record. `all` takes no other names:
`all,notes` fails.
- 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`,
Expand All @@ -80,9 +92,15 @@ record, and `areas` and `tags` rows are short enough not to need it.
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.
`valid` lists every name it takes. An empty `--fields ""` fails the same
way, with `valid` and no `unknown`.
- `--fields` needs `--json`; without it the command fails rather than
ignore the flag. Plain output is unchanged.
- In v0.10.0 and earlier, `--json` printed the full record, as
`--fields all` does now. A script that reads `notes`,
a parent uuid, `index`, `todayIndex`, `trashed`, `creationDate`, or
`startBucket` on a `things projects` row, needs `--fields all`, or
`--fields` naming those keys.

## Listing

Expand Down Expand Up @@ -355,8 +373,9 @@ cut down to 10. Piped output keeps every column whole.

Projects are scheduled the same way tasks are, and `things projects -j`
reports that with the same field names and encodings: `start`,
`startBucket`, `startDate` and `deadline`. A caller can tell a scheduled
project from an anytime one without a `things show` per project.
`startDate`, `deadline`, and `startBucket` when `--fields` is `all` or
names it. A caller can tell a scheduled project from an anytime one without
a `things show` per project.

`things projects -j` also reports two counts per project. `taskCount` is
every untrashed task in the project; `openCount` is the ones still open.
Expand Down
52 changes: 52 additions & 0 deletions internal/output/docs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package output

import (
"os"
"path/filepath"
"strings"
"testing"
)

// readPage reads a docs page with its whitespace runs folded to one space, so
// a list wrapped across lines matches as one line.
func readPage(t *testing.T, path string) string {
t.Helper()
body, err := os.ReadFile(filepath.FromSlash(path))
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return strings.Join(strings.Fields(string(body)), " ")
}

// codeList renders names as the pages write them: `a`, `b`, `c`, with last
// joining the final two ("" for a plain comma).
func codeList(names []string, last string) string {
quoted := make([]string, len(names))
for i, n := range names {
quoted[i] = "`" + n + "`"
}
if last == "" || len(quoted) < 2 {
return strings.Join(quoted, ", ")
}
return strings.Join(quoted[:len(quoted)-1], ", ") + " " + last + " " + quoted[len(quoted)-1]
}

// The command reference and the skill spell out the keys --fields takes and
// the default sets, which drift silently from the struct tags and the
// variables above unless something checks them.
func TestDocsListFieldNames(t *testing.T) {
for _, c := range []struct {
path, want string
}{
{"../../docs/content/commands.md", "A task listing takes the task keys: " + codeList(TaskFields, "") + "."},
{"../../docs/content/commands.md", "`things projects` takes the project keys: " + codeList(ProjectFields, "") + "."},
{"../../docs/content/commands.md", "a task row carries these keys: " + codeList(TaskDefaultFields, "") + "."},
{"../../docs/content/commands.md", "A project row from `things projects` carries these keys: " + codeList(ProjectDefaultFields, "") + "."},
{"../../internal/skill/SKILL.md", "Task rows carry " + codeList(TaskDefaultFields, "and") + ";"},
{"../../internal/skill/SKILL.md", "`things projects` rows carry " + codeList(ProjectDefaultFields, "and") + " "},
} {
if !strings.Contains(readPage(t, c.path), c.want) {
t.Errorf("%s does not say %q", c.path, c.want)
}
}
}
Loading
Loading