From 361440616e11dbb0ea7b3465a29efaf4065e0670 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sat, 10 Oct 2026 15:11:10 +0100 Subject: [PATCH] fix(when): report a --when the read-back cannot place as not confirmed --- cmd/things/add_test.go | 116 ++++++++++++++++++++++++++++++++++++++- cmd/things/verify.go | 11 +++- cmd/things/when.go | 34 ++++++++++++ docs/content/agents.md | 9 ++- docs/content/commands.md | 13 ++++- internal/skill/SKILL.md | 6 +- 6 files changed, 180 insertions(+), 9 deletions(-) diff --git a/cmd/things/add_test.go b/cmd/things/add_test.go index a22bf40..1ae3d06 100644 --- a/cmd/things/add_test.go +++ b/cmd/things/add_test.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "encoding/json" + "fmt" "io" "net/url" "os/exec" @@ -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 { @@ -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) } @@ -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 @@ -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 diff --git a/cmd/things/verify.go b/cmd/things/verify.go index f8e2539..bc4e055 100644 --- a/cmd/things/verify.go +++ b/cmd/things/verify.go @@ -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 @@ -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) } @@ -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) } diff --git a/cmd/things/when.go b/cmd/things/when.go index 79c7b0a..aba406a 100644 --- a/cmd/things/when.go +++ b/cmd/things/when.go @@ -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 diff --git a/docs/content/agents.md b/docs/content/agents.md index a621873..273daa7 100644 --- a/docs/content/agents.md +++ b/docs/content/agents.md @@ -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. @@ -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. diff --git a/docs/content/commands.md b/docs/content/commands.md index 65168fd..c178290 100644 --- a/docs/content/commands.md +++ b/docs/content/commands.md @@ -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"`. @@ -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 diff --git a/internal/skill/SKILL.md b/internal/skill/SKILL.md index 707b0d0..8a0a177 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -146,9 +146,9 @@ Error: 1 of 2 requested status changes did not apply. …: The rest of that import is already applied — re-run with only the failed items. -`edit` and `project edit` wait for Things to record the write (the item's modification date changes, or with a checklist flag the checklist changes, since Things leaves the date alone for a checklist-only change; with `--complete`/`--cancel` the status must change too, and a status-only edit is checked on the status alone), then print the item exactly as `things show` would — the same object under `--json`. **Exit 0 with the item printed means the edit is confirmed: do not `things show` it again.** A dropped edit exits non-zero with `edit did not apply: …` after the read-back wait (5s by default; see `--verify-timeout` below). An edit that only re-sets values the item already has is caught before the wait and succeeds when every flag is `--title`, `--notes`, `--tags`, `--add-tags` (tags compared case-insensitively), a `--deadline` date, an empty `--append-notes`/`--prepend-notes`, or a `--when` of `anytime`, `someday`, `today`, `evening`, `tomorrow`, empty, a date, an `HH:MM` time or a `YYYY-MM-DD@HH:MM` (`today`, `evening`, today's date or a past date clears a reminder, so on an item with one it is a change and waits; a time is no change only when the item already has that reminder on the day it lands; an open item in Today from an earlier day keeps that day as its start date, since Things does not move it forward, and counts as filed today: `--when today` on such a to-do in the day part is no change, and today's date, a past date, or `today`/`evening` that leaves it in its part of the day is read back and confirmed whether Things moves its date to today or keeps the earlier one, unless the item still has a reminder those values clear; any other value, such as `tomorrow`, must move it); a tag that does not exist in Things counts as no change, since Things drops it, unless `--create-tags` creates it. Any other flag still waits, and a no-op there reports the same error; an English `--when` phrase on its own is not waited for (see `--when` below). With `--when`, the read-back also checks the item landed where the value puts it (Anytime, Someday, today, this evening, the date); modified but filed elsewhere exits non-zero with `edit did not apply as sent: …`. `add`/`project add` check `--when` the same way and exit non-zero with `add did not apply as sent: …` naming the item found: search for the title before adding it again. Under `--json` either is one error object `{"error": "misfiled", "kind": …, "uuid": …, "title": …, "landed": …, "message": …}`. **On that error, run `things show ` before retrying: if the item already shows what you asked for, it is done; do not retry blindly.** `--duplicate` edits a new copy the CLI cannot find, so nothing is read back; under `--no-verify` neither is anything. Both print a line saying the edit was sent but not confirmed — under `--json`, `{"uuid": …, "title": …, "confirmed": false, "reason": "duplicate"|"no-verify"|"when"}` (`"when"`: an edit whose only change is a free `--when` phrase, which is not read back; see `--when` below). +`edit` and `project edit` wait for Things to record the write (the item's modification date changes, or with a checklist flag the checklist changes, since Things leaves the date alone for a checklist-only change; with `--complete`/`--cancel` the status must change too, and a status-only edit is checked on the status alone), then print the item exactly as `things show` would — the same object under `--json`. **Exit 0 with the item printed means the edit is confirmed: do not `things show` it again.** A dropped edit exits non-zero with `edit did not apply: …` after the read-back wait (5s by default; see `--verify-timeout` below). An edit that only re-sets values the item already has is caught before the wait and succeeds when every flag is `--title`, `--notes`, `--tags`, `--add-tags` (tags compared case-insensitively), a `--deadline` date, an empty `--append-notes`/`--prepend-notes`, or a `--when` of `anytime`, `someday`, `today`, `evening`, `tomorrow`, empty, a date, an `HH:MM` time or a `YYYY-MM-DD@HH:MM` (`today`, `evening`, today's date or a past date clears a reminder, so on an item with one it is a change and waits; a time is no change only when the item already has that reminder on the day it lands; an open item in Today from an earlier day keeps that day as its start date, since Things does not move it forward, and counts as filed today: `--when today` on such a to-do in the day part is no change, and today's date, a past date, or `today`/`evening` that leaves it in its part of the day is read back and confirmed whether Things moves its date to today or keeps the earlier one, unless the item still has a reminder those values clear; any other value, such as `tomorrow`, must move it); a tag that does not exist in Things counts as no change, since Things drops it, unless `--create-tags` creates it. Any other flag still waits, and a no-op there reports the same error; an English `--when` phrase on its own is not waited for (see `--when` below). With `--when`, the read-back also checks the item landed where the value puts it (Anytime, Someday, today, this evening, the date); modified but filed elsewhere exits non-zero with `edit did not apply as sent: …`. `add`/`project add` check `--when` the same way and exit non-zero with `add did not apply as sent: …` naming the item found: search for the title before adding it again. Under `--json` either is one error object `{"error": "misfiled", "kind": …, "uuid": …, "title": …, "landed": …, "message": …}`. **On that error, run `things show ` before retrying: if the item already shows what you asked for, it is done; do not retry blindly.** `--duplicate` edits a new copy the CLI cannot find, so nothing is read back; under `--no-verify` neither is anything. Both print a line saying the edit was sent but not confirmed — under `--json`, `{"uuid": …, "title": …, "confirmed": false, "reason": "duplicate"|"no-verify"|"when"}` (`"when"`: an edit whose only change is a free `--when` phrase, which is not read back, or a `--when` the CLI cannot place, applied but not checked; see `--when` below). -`add` and `project add` find the item they created (an item of that kind with that title, filed in the project, area and heading the add sent it to — or in none when it named none; when several projects, or several areas, share the title the add named, the one Things picks — created after the write and not there before it) and print it exactly as `things show` would. **Exit 0 with the item printed means it was created: use its `uuid`, do not `things search` for it.** If it never appears they exit non-zero with `add not confirmed: …`; **run the `things search` command the error prints (it keeps your `--db` and `--config`) before retrying, or a retry may create a duplicate.** A same-titled item another command adds elsewhere at the same moment is not taken for yours (unless the database could not be read to resolve where the add was sent). If more than one new item with that title is visible together in the place the add sent it, they exit 0 with a line saying the add is not confirmed and listing the candidate uuids — under `--json`, `{"title": …, "confirmed": false, "reason": "ambiguous", "candidates": [...]}`. `--no-verify` prints the same shape with reason `"no-verify"`, and an unreadable database gives `"unreadable"` with a warning. **Exit 0 with `"confirmed": false` is not a confirmation**: search for the title before acting on it. +`add` and `project add` find the item they created (an item of that kind with that title, filed in the project, area and heading the add sent it to — or in none when it named none; when several projects, or several areas, share the title the add named, the one Things picks — created after the write and not there before it) and print it exactly as `things show` would. **Exit 0 with the item printed means it was created: use its `uuid`, do not `things search` for it.** If it never appears they exit non-zero with `add not confirmed: …`; **run the `things search` command the error prints (it keeps your `--db` and `--config`) before retrying, or a retry may create a duplicate.** A same-titled item another command adds elsewhere at the same moment is not taken for yours (unless the database could not be read to resolve where the add was sent). If more than one new item with that title is visible together in the place the add sent it, they exit 0 with a line saying the add is not confirmed and listing the candidate uuids — under `--json`, `{"title": …, "confirmed": false, "reason": "ambiguous", "candidates": [...]}`. `--no-verify` prints the same shape with reason `"no-verify"`, and an unreadable database gives `"unreadable"` with a warning. A `--when` the CLI cannot place gives `"when"` with the new item's `uuid` (see `--when` below): the item exists, `things show` it. **Exit 0 with `"confirmed": false` is not a confirmation**: search for the title before acting on it. `import` finds every to-do and project its payload creates the same way, nested ones included (checked against where Things files it: a `heading-id` wins over any list and makes Things ignore `heading`; a `list-id`, even empty or unknown, wins over `list`; a `list-id` or `heading-id` Things has files into it whatever its state, closed, logged or trashed; an empty or unknown `list-id`, or a `list` title that matches nothing, means the Inbox; an empty or unknown `area-id`, or an `area` that matches nothing, means no area; Things trims neither ids nor list titles; a `list` title several projects or areas share is checked against the one Things picks, and one a project created or renamed earlier in the same payload carries fits any list with that title; a `heading` the list lacks is dropped unless the payload creates one with that title earlier; a destination that is not a string or null makes Things reject the payload, so `import` refuses it (`invalid-type`); it warns about each item Things will not file where the payload asks, and notes a shared title or a closed or trashed project as `add` does) (headings and checklist items are not checked), and prints one line per item — under `--json`, an array of `{"path", "kind", "title", "uuid", "confirmed": true}`, or for an unconfirmed item `"confirmed": false` with `reason` `"no-verify"`, `"unreadable"`, `"ambiguous"` (with `candidates`), `"creation-date"` (the payload set the item's `creation-date`, so it is not checked) or `"when"` (its `when` is a phrase Things did not understand, so it has no start date; carries `uuid` and `landed`) and exit 0. A `creation-date` or `completion-date` (on any item, headings and checklist items included) must be a date and time with seconds and a UTC offset (`2026-10-05T10:30:00Z`); Things rejects the whole payload over a date on its own, a time with no offset, a month outside 1 to 12 or an offset such as `+200`, so `import` refuses it before sending anything (`import refused`, with `blocked` naming the attribute, reason `invalid-date`). The CLI is stricter than Things on malformed dates such as `+012` or a five-digit year, which Things guesses at or drops. One in the future, more than a minute ahead, is refused with reason `future-date`: Things would save it as now. A `when` or `deadline` (created or updated to-dos and projects) gets the checks `add` makes, and one starting with a digit must be a real `YYYY-MM-DD`; a `when` with `@` needs `today`, `tomorrow`, `evening` or a real date before it and a real time (`18:00`, `6pm`) after it (a date, `today` or `tomorrow` with a time is sent as `YYYY-MM-DD@HH:MM`, as `add` sends it, and read back like one), and a deadline takes no time: Things files `2026-13-01` or a bare `18:00` in Today and `tomorrow@25:00` with no reminder without a word, and drops the time in `someday@18:00`, so these are refused with reason `invalid-date`. A key given twice in one object (Things keeps the first) is refused with reason `duplicate-key`; a title over 4000 characters (Things cuts it) or notes over 10000 with `too-long`; `items` on a to-do or `checklist-items` on a project, which Things ignores, with `invalid-item`. Things also rejects the whole payload over an item it does not take, so `import` refuses: an item that is not an object, a `type` other than exactly `to-do`, `project`, `heading` or `checklist-item` (not trimmed), an `operation` other than exactly `create` or `update`, a created item with no `attributes` or in a place Things does not allow it (top level: to-do or project; a project's `items`: to-do or heading; a to-do's `checklist-items`: checklist-item) (reason `invalid-item`); and on a created item, `attributes` that is not an object, a `title`, `notes`, `when` or `deadline` that is not a string, `tags` that is not an array of strings, `completed` or `canceled` that is not `true`/`false`, or `items`/`checklist-items` that is not an array (`null` is fine) (reason `invalid-type`). Item titles are reported as Things saves them, padding included. A to-do or project created with no title, or only whitespace, is refused with reason `blank-title`: Things would create it untitled. A payload that creates more than 200 items (to-dos, projects and headings at any depth; update and checklist items not counted) is refused with top-level `"reason": "too-many-items"` (and no `items` when size is the only problem): above that Things stops to ask before creating anything, so split it and pause between the imports (Things is believed to allow about 250 items per 10 seconds; not verified). Same-titled items are paired with new items in the order Things saved them, or all reported `"ambiguous"` when two were saved at the same instant. If any created item never appeared (or fewer appeared than the payload created), it exits non-zero with `import partially applied`: `items` lists the missing ones with reason `"not-found"`. An item without a `creation-date` that shares its kind and title with one whose `creation-date` is recent (within the last minute or later) cannot be told apart from it where both could be filed, so it is reported with reason `"shares-dated-title"` and `candidates` listing the new items it could be; it fails the import the same way only when fewer new items appeared than the items that could claim them (it may still exist: search before re-running), and exits 0 unconfirmed when there is one for each; the item carries `"present": true` when it is there and `false` when it may be missing (no other item carries `present`). A completed or canceled item saved without the payload's `completion-date` also fails it, with reason `"completion-date-dropped"` and its `id`: it is there, so set the date in Things rather than importing it again. So does an open item whose keyword or date `when` Things did not apply (a to-do with `when` `today` and a deadline already past lands in the Inbox with no start date), with reason `"misfiled"`, its `id` and `landed`, as `add` reports it: move it with `things edit` (`things project edit` for a project) rather than importing it again. `created` carries every created item's verdict, so use the uuids of the confirmed ones from there; **search for each listed title before re-running the import with only those items, or a retry may create duplicates.** @@ -363,7 +363,7 @@ A plain listing prints each task on one line: a line break or tab in a title, ta ## Date and multi-line values -`--when` takes a keyword (`today`, `tomorrow`, `evening`, `anytime`, `someday`), a date `YYYY-MM-DD`, a time `HH:MM` or a 12-hour time (`9:30PM`, `6pm`; sent to Things as `21:30`, `18:00` and read back like one), a date+time `YYYY-MM-DD@HH:MM` (also `today@6pm`, `tomorrow@18:00`, `2026-05-01@9pm`: sent to Things as `YYYY-MM-DD@HH:MM`, with today and tomorrow as their dates when the command runs, and read back like one; `evening@6pm` is sent as typed and not checked), or an RFC3339 timestamp. An RFC3339 timestamp keeps its wall-clock time and ignores its offset: `2026-05-01T09:00:00+05:00` sets 09:00 local, so convert to local time before passing one. English phrases (`friday`, `next monday`) are passed through to Things. A date and time with no offset (`2026-10-09T10:00`) is read as that wall-clock time. A value shaped like a date or time that names none (`2026-13-01`, `25:00`, `tomorrow@25:00`, `2026-10-09T25:00:00Z`), or a time after `anytime@`/`someday@`, is refused before anything is sent. A free phrase (anything but a keyword, a weekday name, a date or a time) is not checked: `add` warns on stderr when the new item has no start date, and still prints the item and exits 0 (it exists, without the `--when`); an `edit` whose only change is a free phrase is not read back and returns at once unconfirmed, with a warning (`{"uuid", "title", "confirmed": false, "reason": "when"}`: check it with `things show`), since Things records nothing when it ignores one; with other changes the edit waits as usual, and if nothing was modified its error names the phrase. Likely keyword typos (edit distance ≤ 2, e.g. `tommorrow`) are rejected with a "did you mean" hint. +`--when` takes a keyword (`today`, `tomorrow`, `evening`, `anytime`, `someday`), a date `YYYY-MM-DD`, a time `HH:MM` or a 12-hour time (`9:30PM`, `6pm`; sent to Things as `21:30`, `18:00` and read back like one), a date+time `YYYY-MM-DD@HH:MM` (also `today@6pm`, `tomorrow@18:00`, `2026-05-01@9pm`: sent to Things as `YYYY-MM-DD@HH:MM`, with today and tomorrow as their dates when the command runs, and read back like one; `evening@6pm` is sent as typed and not checked), or an RFC3339 timestamp. An RFC3339 timestamp keeps its wall-clock time and ignores its offset: `2026-05-01T09:00:00+05:00` sets 09:00 local, so convert to local time before passing one. English phrases (`friday`, `next monday`) are passed through to Things. A date and time with no offset (`2026-10-09T10:00`) is read as that wall-clock time. A value shaped like a date or time that names none (`2026-13-01`, `25:00`, `tomorrow@25:00`, `2026-10-09T25:00:00Z`), or a time after `anytime@`/`someday@`, is refused before anything is sent. A free phrase (anything but a keyword, a weekday name, a date or a time) is not checked: `add` warns on stderr when the new item has no start date, and still prints the item and exits 0 (it exists, without the `--when`); an `edit` whose only change is a free phrase is not read back and returns at once unconfirmed, with a warning (`{"uuid", "title", "confirmed": false, "reason": "when"}`: check it with `things show`), since Things records nothing when it ignores one; with other changes the edit waits as usual, and if nothing was modified its error names the phrase. A value the CLI cannot place — a weekday name (`friday`), a weekday or `evening` with a time (`friday@18:00`, `evening@6pm`), or a phrase Things understood — is not checked against a place: once Things applies the write, `add`, `project add` and `edit` warn on stderr with where the item is and report it not confirmed, exit 0 (`Created, not confirmed (--when not checked): "…" (uuid)`, `Applied, …` for an edit; under `--json` `{"uuid", "title", "confirmed": false, "reason": "when"}`): check it with `things show`. Likely keyword typos (edit distance ≤ 2, e.g. `tommorrow`) are rejected with a "did you mean" hint. `--deadline` takes `YYYY-MM-DD` or an English phrase; keywords like `today` are rejected.