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
116 changes: 115 additions & 1 deletion cmd/things/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"net/url"
"os/exec"
Expand Down Expand Up @@ -1120,7 +1121,8 @@ func TestAddInvalidRefusedBeforeTagsCreated(t *testing.T) {
// A free phrase Things ignores leaves the new item with no start date. The
// add exits 0 and prints the item, since it exists and was found, and warns
// on stderr in both output modes that the --when did not take. A phrase
// Things understood gives the item a date, and nothing is said.
// Things understood gives the item a date, which the read-back cannot
// check, so the add is reported not confirmed (whenPlaced).
func TestAddWhenPhraseIgnored(t *testing.T) {
dated := "start = 1, startDate = " + strconv.Itoa(int(model.ThingsDateFromTime(time.Now().AddDate(0, 0, 3))))
cases := []struct {
Expand Down Expand Up @@ -1150,6 +1152,10 @@ func TestAddWhenPhraseIgnored(t *testing.T) {
if got := strings.Contains(stderr, warning); got != tc.warn {
t.Errorf("stderr = %q, want the warning %v", stderr, tc.warn)
}
if !tc.warn {
checkWhenUnchecked(t, stdout, stderr, "new-1", "Buy oat milk", "blorp", asJSON)
return
}
if want, _ := runOut(t, database, show...); stdout != want {
t.Errorf("stdout = %q, want the show output %q", stdout, want)
}
Expand All @@ -1158,6 +1164,68 @@ func TestAddWhenPhraseIgnored(t *testing.T) {
}
}

// checkWhenUnchecked checks an add or edit of the item uuid, titled title,
// with --when value was reported not confirmed because the read-back cannot
// check value, in the output mode asJSON says.
func checkWhenUnchecked(t *testing.T, stdout, stderr, uuid, title, value string, asJSON bool) {
t.Helper()
if want := fmt.Sprintf("cannot check where --when %q files an item", value); !strings.Contains(stderr, want) {
t.Errorf("stderr = %q, want %q", stderr, want)
}
if !asJSON {
if !strings.Contains(stdout, fmt.Sprintf("not confirmed (--when not checked): %q (%s)", title, uuid)) {
t.Errorf("stdout = %q, want the unconfirmed line", stdout)
}
return
}
var out map[string]any
if err := json.Unmarshal([]byte(stdout), &out); err != nil {
t.Fatalf("decode %s: %v", stdout, err)
}
if out["confirmed"] != false || out["reason"] != "when" || out["uuid"] != uuid || out["title"] != title {
t.Errorf("stdout = %s, want confirmed false, reason when, uuid %s", stdout, uuid)
}
}

// A --when the read-back cannot place, a weekday name, a weekday or evening
// with a time, is reported not confirmed for add and project add, whatever
// date Things gave the item. A value it can place is judged as before.
func TestAddWhenUnplaced(t *testing.T) {
dated := "start = 2, startDate = " + strconv.Itoa(int(model.ThingsDateFromTime(testNow.AddDate(0, 0, 3))))
for _, value := range []string{"friday", "friday@18:00", "evening@6pm"} {
for _, typ := range []model.TaskType{model.TypeTask, model.TypeProject} {
for _, asJSON := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/%s/json=%v", value, typ, asJSON), func(t *testing.T) {
fastVerify(t)
database, sqlDB := seedWritable(t)
stubExecAdding(t, sqlDB, createdRow{uuid: "new-1", title: "Buy oat milk", typ: typ, extra: dated})
args := []string{"add", "Buy oat milk", "--when", value}
if typ == model.TypeProject {
args = append([]string{"project"}, args...)
}
if asJSON {
args = append([]string{"--json"}, args...)
}
stdout, stderr, err := runStreams(t, database, args...)
if err != nil {
t.Fatalf("add: %v", err)
}
checkWhenUnchecked(t, stdout, stderr, "new-1", "Buy oat milk", value, asJSON)
})
}
}
}
// A date with a time is placed: filed on its day, it is confirmed.
fastVerify(t)
database, sqlDB := seedWritable(t)
stubExecAdding(t, sqlDB, createdRow{uuid: "new-1", title: "Buy oat milk", extra: dated})
day := testNow.AddDate(0, 0, 3).Format("2006-01-02")
stdout, stderr, err := runStreams(t, database, "--json", "add", "Buy oat milk", "--when", day+"@18:00")
if err != nil || strings.Contains(stderr, "cannot check") || strings.Contains(stdout, `"confirmed"`) {
t.Errorf("add --when %s@18:00 = %v, stdout %s, stderr %q; want the item printed", day, err, stdout, stderr)
}
}

// An edit whose only change is a --when phrase is not read back: Things
// records nothing for a phrase it ignores, so the wait could only run out.
// It returns at once, unconfirmed with reason "when". With another change
Expand Down Expand Up @@ -1198,6 +1266,52 @@ func TestEditWhenPhrase(t *testing.T) {
}
}

// An edit with a --when the read-back cannot place, which Things applied, is
// reported not confirmed, with where the item is. One it can place prints the
// item as before. --notes keeps a weekday with a time, which an edit of
// --when alone treats as a phrase (TestEditWhenPhrase), read back.
func TestEditWhenUnplaced(t *testing.T) {
friday := strconv.Itoa(int(model.ThingsDateFromTime(testNow.AddDate(0, 0, 3))))
for _, tc := range []struct {
value string
placed bool
}{
{"friday", false},
{"friday@18:00", false},
{"evening@6pm", false},
{testNow.AddDate(0, 0, 3).Format("2006-01-02"), true},
} {
for _, asJSON := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/json=%v", tc.value, asJSON), func(t *testing.T) {
fastVerify(t)
database, sqlDB := seedWritable(t)
prev := things.SetExecCommandForTest(func(string, ...string) *exec.Cmd {
if _, err := sqlDB.Exec(`UPDATE TMTask SET start = 2, startDate = ` + friday + `, userModificationDate = COALESCE(userModificationDate, 0) + 1 WHERE uuid = 'one-1'`); err != nil {
t.Errorf("simulating Things write: %v", err)
}
return exec.Command("true")
})
t.Cleanup(func() { things.SetExecCommandForTest(prev) })
args := []string{"edit", "one-1", "--when", tc.value, "--notes", "x"}
if asJSON {
args = append([]string{"--json"}, args...)
}
stdout, stderr, err := runStreams(t, database, args...)
if err != nil {
t.Fatalf("edit: %v", err)
}
if tc.placed {
if strings.Contains(stderr, "cannot check") || strings.Contains(stdout, "not confirmed") {
t.Errorf("stdout %q, stderr %q; want the item printed", stdout, stderr)
}
return
}
checkWhenUnchecked(t, stdout, stderr, "one-1", "Post letter", tc.value, asJSON)
})
}
}
}

// A bare 12-hour time is a time, not a phrase: it is sent as the HH:MM
// Things was measured with, and read back like one. An edit Things drops
// waits for the modification date instead of returning unconfirmed, and an
Expand Down
11 changes: 10 additions & 1 deletion cmd/things/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ func applyEdit(d *Deps, database *db.DB, task *model.Task, changed, checklist bo
return printUnconfirmedEdit(d, task, reasonNoVerify, unconfirmedMsg[reasonNoVerify])
case when != nil && when.phraseOnly && want == task.Status:
fmt.Fprintf(d.errOut(), "warning: Things may not understand --when %q, and records nothing when it ignores one, so the edit is not read back; run `things show %s` to check\n", when.value, task.UUID)
return printUnconfirmedEdit(d, task, "when", "Sent to Things, not confirmed (--when phrase)")
return printUnconfirmedEdit(d, task, whenUnchecked, "Sent to Things, not confirmed (--when phrase)")
}

current := task
Expand All @@ -353,6 +353,10 @@ func applyEdit(d *Deps, database *db.DB, task *model.Task, changed, checklist bo
}
current = res.task
}
if when != nil && !whenPlaced(when.judged()) {
fmt.Fprintf(d.errOut(), "warning: Things applied the edit, but the CLI cannot check where --when %q files an item, so it is not confirmed; it is %s. Run `%s` to check\n", when.value, describeStart(current), strings.Join(thingsCmd(d, "show", current.UUID), " "))
return printUnconfirmedEdit(d, current, whenUnchecked, "Applied, not confirmed (--when not checked)")
}
return printItem(d, database, current)
}

Expand Down Expand Up @@ -669,6 +673,11 @@ func applyAdd(d *Deps, typ model.TaskType, title string, dest createdDest, when,
// failure, but the --when was not applied and the caller has to
// know.
fmt.Fprintf(d.errOut(), "warning: Things did not understand --when %q; the %s was created %s\n", when, kindNoun(typ), describeStart(item))
default:
if whenSent != nil && !whenPlaced(whenSent.judged()) {
fmt.Fprintf(d.errOut(), "warning: the CLI cannot check where --when %q files an item, so the %s is not confirmed; it was created %s. Run `%s` to check\n", when, kindNoun(typ), describeStart(item), strings.Join(thingsCmd(d, "show", item.UUID), " "))
return printUnconfirmedEdit(d, item, whenUnchecked, "Created, not confirmed (--when not checked)")
}
}
return printItem(d, database, item)
}
Expand Down
34 changes: 34 additions & 0 deletions cmd/things/when.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,40 @@ func placeWhen(value string, now time.Time) (place whenPlace, ok bool) {
return whenPlace{day: day, bucket: -1, reminder: reminderKept}, true
}

// whenPlaced reports whether value, as sent, is one placeWhen works out where
// it files an item: anytime, someday, today, evening, tomorrow, an empty
// value, an HH:MM time, a date, or a date and time. The read-back checks only
// these; for any other value, a weekday name, a word other than a date with
// a time after the @ (evening@18:00, friday@18:00) or an English phrase, it
// confirms only that Things applied the write, so the item is reported not
// confirmed (whenUnchecked).
func whenPlaced(value string) bool {
v, err := things.NormalizeWhen(value)
if err != nil {
return false
}
switch v {
case "", "anytime", "someday", "today", "evening", "tomorrow":
return true
}
if _, ok := parseClock(v); ok {
return true
}
if len(v) == len("2006-01-02@15:04") && v[10] == '@' {
if _, ok := parseClock(v[11:]); !ok {
return false
}
v = v[:10]
}
_, err = time.ParseInLocation("2006-01-02", v, time.Local)
return err == nil
}

// whenUnchecked is the reason an add or edit gives for an item reported not
// confirmed because the read-back cannot check its --when (whenPlaced), as
// for an edit whose only change is a free phrase.
const whenUnchecked = "when"

// whenPhrase reports whether value goes to Things as a free phrase: one that
// is none of the keywords or weekday names, a time, a date or a date and
// time (things.KnownWhenWord, things.WhenDateOrTime), so
Expand Down
9 changes: 8 additions & 1 deletion docs/content/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,10 @@ instead of assuming:
for the title before retrying, so a retry does not make a duplicate.
Exit 0 with `"confirmed": false` (two new items with the title in the same place,
`--no-verify`, or an unreadable database) is not a confirmation either.
Nor is `"confirmed": false` with reason `when` and the item's `uuid`:
the item was created, but the CLI cannot check where a `--when` of a
weekday name, a weekday or `evening` with a time, or an English phrase
filed it, so `things show` it.
`import` does the same for every to-do and project it creates, and
prints one verdict per item, with its `path` in the payload. It exits
non-zero with `import partially applied` if any of them never appeared.
Expand Down Expand Up @@ -545,7 +549,10 @@ instead of assuming:
re-running a plan does not fail on work already done. With `--when`, the
item must also land where the value puts it; `add` checks the same and,
when it does not, names the item it found (`"error": "misfiled"` under
`--json`) so the agent can search before adding it again. With
`--json`) so the agent can search before adding it again. A `--when`
the CLI cannot place (a weekday name, a weekday or `evening` with a
time, an English phrase) is applied but reported `"confirmed": false`
with reason `when`, as for `add`. With
`--no-verify` or `--duplicate` nothing is read back and the output says
the edit is unconfirmed.
- **Tags must already exist.** Things silently drops tags it does not know.
Expand Down
13 changes: 10 additions & 3 deletions docs/content/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,8 @@ read-back (five seconds by default; see `--verify-timeout` under Editing).
Under `--json` the unconfirmed cases print `{"title": …, "confirmed":
false, "reason": "no-verify"|"unreadable"|"ambiguous"}`, with
`"candidates": [uuids]` for `ambiguous`. There is no `uuid`, because the
add did not return one. An add that `--when` did not file where it said
add did not return one. A `--when` the read-back cannot check is reported
with reason `when` and the new item's `uuid` (below). An add that `--when` did not file where it said
(below) is an error instead: under `--json` its one object has `"error":
"misfiled"`, the item's `"uuid"` and where it `"landed"`.

Expand Down Expand Up @@ -773,8 +774,14 @@ add` check the same, and exit non-zero with `add did not apply as sent: …`
naming the item they found; search for the title before adding it again,
since another add of the same title at the same moment can be the one
found. Under `--json` either error is one object with `"error":
"misfiled"`, the item's `"uuid"` and where it `"landed"`. An English phrase is
not checked against a place. A value shaped like a date or time that names
"misfiled"`, the item's `"uuid"` and where it `"landed"`. A value the CLI
cannot place, a weekday name (`friday`), a weekday or `evening` with a time
(`friday@18:00`, `evening@6pm`) or an English phrase, is not checked
against a place. Once Things has applied the write, `add`, `project add`
and `edit` warn on stderr with where the item is, print `Created, not
confirmed (--when not checked): "…" (uuid)` (`Applied, not confirmed …`
for an edit) and exit 0; under `--json`, `{"uuid", "title", "confirmed":
false, "reason": "when"}`. Check the item with `things show`. A value shaped like a date or time that names
none, such as `2026-13-01`, `25:00`, `tomorrow@25:00` or
`2026-10-09T25:00:00Z`, or a time after `anytime@` or `someday@`, is refused
before anything is sent; a date and time with no offset, such as
Expand Down
Loading
Loading