From 663ed2bc4e1212d09631e38d363658250450e484 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sat, 10 Oct 2026 15:31:48 +0100 Subject: [PATCH] fix(when): send weekday names as their date and check evening@time, in add, edit and import --- cmd/things/add_test.go | 105 ++++++++++++++++-- cmd/things/import_test.go | 7 +- cmd/things/importcreate_test.go | 7 ++ cmd/things/importreadback.go | 32 ++++-- cmd/things/importshape_test.go | 2 +- cmd/things/importvalidate.go | 15 +-- cmd/things/importvalidate_test.go | 7 +- cmd/things/verify.go | 8 +- cmd/things/when.go | 170 ++++++++++++++++++------------ cmd/things/when_test.go | 6 ++ docs/content/agents.md | 10 +- docs/content/commands.md | 39 ++++--- internal/skill/SKILL.md | 4 +- internal/things/dates.go | 68 +++++++++--- internal/things/dates_test.go | 27 +++-- 15 files changed, 368 insertions(+), 139 deletions(-) diff --git a/cmd/things/add_test.go b/cmd/things/add_test.go index e7ba6166..2867dc19 100644 --- a/cmd/things/add_test.go +++ b/cmd/things/add_test.go @@ -14,6 +14,7 @@ import ( "time" "unicode" + "github.com/ryanlewis/things-cli/internal/clock" "github.com/ryanlewis/things-cli/internal/config" "github.com/ryanlewis/things-cli/internal/db" "github.com/ryanlewis/things-cli/internal/db/dbtest" @@ -1187,12 +1188,13 @@ func checkWhenUnchecked(t *testing.T, stdout, stderr, uuid, title, value string, } } -// 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. +// A --when the read-back cannot place, an abbreviated weekday or a phrase +// Things understood, 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 _, value := range []string{"fri", "fri@18:00", "next friday"} { 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) { @@ -1268,17 +1270,17 @@ 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. +// item as before. --notes keeps a phrase, which an edit of --when alone does +// not read back (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}, + {"fri", false}, + {"fri@18:00", false}, + {"next friday", false}, {testNow.AddDate(0, 0, 3).Format("2006-01-02"), true}, } { for _, asJSON := range []bool{false, true} { @@ -1416,3 +1418,88 @@ func TestWhenDatedTimeReadBack(t *testing.T) { }) } } + +// A weekday name, alone or with a time, is sent as the date Things gives it, +// the next such day strictly after today, and evening with a time as +// evening@HH:MM. add, project add and edit then read the item back like a +// date or this evening: filed there it is confirmed, filed elsewhere it is +// misfiled. Measured in Things 3 on Saturday 10 Oct 2026; the clock here is +// pinned to the Saturday before. +func TestWhenWeekdayEveningReadBack(t *testing.T) { + sat := time.Date(2026, 10, 3, 14, 0, 0, 0, time.Local) + t.Cleanup(clock.Pin(sat)) + day := func(d int) string { return strconv.Itoa(int(model.ThingsDateFromTime(sat.AddDate(0, 0, d)))) } + dated := func(d int) string { return "start = 2, startDate = " + day(d) } + evening := func(bucket int) string { + return fmt.Sprintf("start = 1, startDate = %s, startBucket = %d", day(0), bucket) + } + for _, tc := range []struct { + in, sent, lands, misfiled string + }{ + {"friday@9pm", "2026-10-09@21:00", dated(6), dated(3)}, + {"Friday", "2026-10-09", dated(6), dated(3)}, + {"sunday@9am", "2026-10-04@09:00", dated(1), dated(8)}, + // Today's weekday is a week on, whether or not the time has passed. + {"saturday", "2026-10-10", dated(7), evening(0)}, + {"saturday@9am", "2026-10-10@09:00", dated(7), evening(0)}, + {"saturday@9pm", "2026-10-10@21:00", dated(7), evening(0)}, + // evening keeps the item this evening, a past time included. + {"evening@6pm", "evening@18:00", evening(1), evening(0)}, + {"evening@9am", "evening@09:00", evening(1), dated(1)}, + } { + for _, landed := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/lands=%v", tc.in, landed), func(t *testing.T) { + row := tc.lands + if !landed { + row = tc.misfiled + } + fastVerify(t) + database, sqlDB := seedWritable(t) + var urls []string + stub := func(apply ...string) { + prev := things.SetExecCommandForTest(func(_ string, a ...string) *exec.Cmd { + urls = append(urls, strings.Join(a, " ")) + for _, q := range apply { + if _, err := sqlDB.Exec(q); err != nil { + t.Errorf("simulating Things write: %v", err) + } + } + return exec.Command("true") + }) + t.Cleanup(func() { things.SetExecCommandForTest(prev) }) + } + check := func(cmd string, err error, stderr string) { + t.Helper() + if want := "when=" + url.QueryEscape(tc.sent); len(urls) != 1 || !strings.Contains(urls[0], want) { + t.Errorf("%s sent %q, want %s", cmd, urls, want) + } + switch { + case landed && (err != nil || strings.Contains(stderr, "cannot check")): + t.Errorf("%s --when %s = %v, stderr %q; want it confirmed", cmd, tc.in, err, stderr) + case !landed && (err == nil || !strings.Contains(err.Error(), "did not apply as sent") || !strings.Contains(err.Error(), strconv.Quote(tc.in))): + t.Errorf("%s --when %s = %v; want it misfiled, naming the value as typed", cmd, tc.in, err) + } + } + + stub(`UPDATE TMTask SET ` + row + `, userModificationDate = COALESCE(userModificationDate, 0) + 1 WHERE uuid = 'one-1'`) + _, stderr, err := runStreams(t, database, "--json", "edit", "one-1", "--when", tc.in) + check("edit", err, stderr) + + for _, add := range [][]string{{"add"}, {"project", "add"}} { + typ := model.TypeTask + if len(add) == 2 { + typ = model.TypeProject + } + urls = nil + uuid := "new-" + typ.String() + now := strconv.FormatFloat(model.TimeToUnix(time.Now()), 'f', -1, 64) + stub(fmt.Sprintf(`INSERT INTO TMTask (uuid, title, type, status, trashed, start, creationDate, userModificationDate) VALUES ('%s', 'Buy oat milk', %d, 0, 0, 0, %s, %s)`, uuid, int(typ), now, now), + `UPDATE TMTask SET `+row+` WHERE uuid = '`+uuid+`'`) + args := append(append([]string{"--json"}, add...), "Buy oat milk", "--when", tc.in) + _, stderr, err := runStreams(t, database, args...) + check(strings.Join(add, " "), err, stderr) + } + }) + } + } +} diff --git a/cmd/things/import_test.go b/cmd/things/import_test.go index 445e5489..7d5b1955 100644 --- a/cmd/things/import_test.go +++ b/cmd/things/import_test.go @@ -161,8 +161,9 @@ func TestRunImportReadsDepsStdin(t *testing.T) { } } -// Each when in an item's attributes goes as add sends --when: a date or -// keyword with a time as YYYY-MM-DD@HH:MM, in a project's items and an +// Each when in an item's attributes goes as add sends --when: a date, +// keyword or weekday with a time as YYYY-MM-DD@HH:MM, a weekday alone as its +// date and evening with a time as evening@HH:MM, in a project's items and an // update alike. Every other value, and every other byte, goes as given. func TestResolveImportWhens(t *testing.T) { now := time.Date(2026, 10, 10, 9, 0, 0, 0, time.Local) @@ -175,6 +176,8 @@ func TestResolveImportWhens(t *testing.T) { `[{"type":"project","attributes":{"title":"P","when":"2026-10-12@19:00","items":[{"type":"to-do","attributes":{"title":"a","when":"2026-10-10@18:00"}}]}},{"type":"to-do","operation":"update","id":"x","attributes":{"when":"2026-10-11@08:00"}}]`}, {"unresolved kept", `[{"type":"to-do","attributes":{"title":"today@6pm","when":"evening@18:00","notes":"when"}},{"type":"to-do","attributes":{"when":"today"}}]`, `[{"type":"to-do","attributes":{"title":"today@6pm","when":"evening@18:00","notes":"when"}},{"type":"to-do","attributes":{"when":"today"}}]`}, + {"weekday and evening", `[{"type":"to-do","attributes":{"when":"friday@9pm"}},{"type":"to-do","attributes":{"when":"saturday"}},{"type":"to-do","attributes":{"when":"evening@6pm"}},{"type":"to-do","attributes":{"when":"fri"}}]`, + `[{"type":"to-do","attributes":{"when":"2026-10-16@21:00"}},{"type":"to-do","attributes":{"when":"2026-10-17"}},{"type":"to-do","attributes":{"when":"evening@18:00"}},{"type":"to-do","attributes":{"when":"fri"}}]`}, {"outside attributes", `[{"type":"to-do","when":"today@6pm","attributes":{"title":"a"}}]`, `[{"type":"to-do","when":"today@6pm","attributes":{"title":"a"}}]`}, {"escapes kept", `[{"type":"to-do","attributes":{"title":"é \"q\"","when":"today@6pm","tags":[1,2.50]}}]`, diff --git a/cmd/things/importcreate_test.go b/cmd/things/importcreate_test.go index 4d187b3f..704e630a 100644 --- a/cmd/things/importcreate_test.go +++ b/cmd/things/importcreate_test.go @@ -1258,6 +1258,13 @@ func TestImportChecksWhen(t *testing.T) { {name: "timedMisfiled", attrs: `"when":"tomorrow@9am"`, fail: true, reason: "misfiled", landed: "in inbox with no start date", message: `[0]: task "Pay rent" (new-1) was created, but its when "tomorrow@9am" did not file it there: it is in inbox with no start date`}, {name: "timedLanded", attrs: `"when":"tomorrow@9am"`, extra: tomorrow}, + // A weekday goes as its date, so one that did not land is caught. + {name: "weekdayMisfiled", attrs: `"when":"friday@9pm"`, fail: true, reason: "misfiled", landed: "in inbox with no start date", + message: `[0]: task "Pay rent" (new-1) was created, but its when "friday@9pm" did not file it there: it is in inbox with no start date`}, + // A value the read-back cannot check is reported as add reports + // it: not confirmed, and the import exits 0. + {name: "unchecked", attrs: `"when":"fri"`, extra: tomorrow, reason: "when", landed: "scheduled for " + testNow.AddDate(0, 0, 1).Format("2006-01-02"), + message: `Created, not confirmed (when not checked): [0] "Pay rent" (new-1), scheduled for ` + testNow.AddDate(0, 0, 1).Format("2006-01-02")}, {name: "noWhen", attrs: `"deadline":"` + past + `"`}, {name: "closedNotChecked", attrs: `"when":"today","completed":true`, extra: "status = 3"}, } { diff --git a/cmd/things/importreadback.go b/cmd/things/importreadback.go index b84b523a..d51ed030 100644 --- a/cmd/things/importreadback.go +++ b/cmd/things/importreadback.go @@ -84,7 +84,9 @@ const whenMisfiled = "misfiled" // whenIgnored is the verdict on a created item whose `when` is a free phrase // Things did not understand: the item was given no start date. As an add with -// such a --when, the item is there and the import does not fail over it. +// such a --when, the item is there and the import does not fail over it. It +// is also the verdict, marked unchecked, on one whose `when` the read-back +// cannot check (whenPlaced), as an add reports such a --when. const whenIgnored = "when" // createdWhere is each created item whose verdict meets keep, in payload @@ -191,6 +193,9 @@ type importCreated struct { Landed string `json:"landed,omitempty"` detail string // why a failing item is missing or unconfirmed, for the plain-text error + // unchecked marks a whenIgnored item whose when the read-back cannot + // check, rather than one Things did not understand. + unchecked bool } // applyImport sends write, the import, passing it the time it is sent, then @@ -538,7 +543,8 @@ func checkClosedAt(database *db.DB, out []importCreated, creates []importCreate, // file it, sent at sent, keeping its uuid, as applyAdd does for --when. It // judges the when as sent (things.ResolveWhen, resolveImportWhens): a // keyword or date that did not land is "misfiled", and a free phrase that -// left the item with no start date is "when". Things files a to-do whose +// left the item with no start date is "when", as is, marked unchecked, a +// value the read-back cannot check (whenPlaced). Things files a to-do whose // `when` it applies with a deadline already past in the Inbox, measured on // 9 Oct 2026, and nothing in the import says so, so only the read-back can // tell. An item the payload completes or cancels is not checked, nor one in @@ -560,10 +566,16 @@ func checkWhens(out []importCreated, creates []importCreate, found map[createdWa out[i].Confirmed, out[i].Reason = false, whenMisfiled case whenNotUnderstood: out[i].Confirmed, out[i].Reason = false, whenIgnored + case whenUnplaced: + // Reported as an add reports it; the import does not fail. + out[i].Confirmed, out[i].Reason, out[i].unchecked = false, whenIgnored, true default: continue } out[i].Landed = describeStart(row) + if out[i].unchecked { + continue + } out[i].detail = fmt.Sprintf("%s %q (%s) was created, but its when %q did not file it there: it is %s", c.typ, c.title, out[i].UUID, c.when, out[i].Landed) } } @@ -586,13 +598,21 @@ func (c importCreated) line() string { case c.Confirmed: return fmt.Sprintf("Created and confirmed: %s %q (%s)", c.Path, c.Title, c.UUID) case len(c.Candidates) > 0: - return fmt.Sprintf("%s: %s %q (%s)", unconfirmedMsg[c.Reason], c.Path, c.Title, strings.Join(c.Candidates, ", ")) + return fmt.Sprintf("%s: %s %q (%s)", c.msg(), c.Path, c.Title, strings.Join(c.Candidates, ", ")) case c.UUID != "" && c.Landed != "": - return fmt.Sprintf("%s: %s %q (%s), %s", unconfirmedMsg[c.Reason], c.Path, c.Title, c.UUID, c.Landed) + return fmt.Sprintf("%s: %s %q (%s), %s", c.msg(), c.Path, c.Title, c.UUID, c.Landed) case c.UUID != "": - return fmt.Sprintf("%s: %s %q (%s)", unconfirmedMsg[c.Reason], c.Path, c.Title, c.UUID) + return fmt.Sprintf("%s: %s %q (%s)", c.msg(), c.Path, c.Title, c.UUID) + } + return fmt.Sprintf("%s: %s %q", c.msg(), c.Path, c.Title) +} + +// msg is the start of the line c prints when it is not confirmed. +func (c importCreated) msg() string { + if c.unchecked { + return "Created, not confirmed (when not checked)" } - return fmt.Sprintf("%s: %s %q", unconfirmedMsg[c.Reason], c.Path, c.Title) + return unconfirmedMsg[c.Reason] } // printImportCreated reports each created item, a line per item or, under diff --git a/cmd/things/importshape_test.go b/cmd/things/importshape_test.go index afe0ae1a..99a6745f 100644 --- a/cmd/things/importshape_test.go +++ b/cmd/things/importshape_test.go @@ -165,7 +165,7 @@ func TestImportRefusesShapesThingsRejects(t *testing.T) { {"when anytime with a time", `[{"type":"project","attributes":{"title":"P","when":"Anytime@9am"}}]`, "[0]", "when", "invalid-date", `[0] when: "Anytime@9am" (Things ignores a time after anytime or someday)`}, {"when a phrase with a time", `[{"type":"to-do","attributes":{"title":"a","when":"next friday@18:00"}}]`, - "[0]", "when", "invalid-date", `[0] when: "next friday@18:00" (before the @ must be today, tomorrow, evening or a date as YYYY-MM-DD)`}, + "[0]", "when", "invalid-date", `[0] when: "next friday@18:00" (before the @ must be today, tomorrow, evening, a weekday name such as friday or a date as YYYY-MM-DD)`}, {"when a bad date with a time", `[{"type":"to-do","attributes":{"title":"a","when":"2026-02-30@18:00"}}]`, "[0]", "when", "invalid-date", `[0] when: "2026-02-30@18:00" (not a real date)`}, {"when 13pm", `[{"type":"to-do","attributes":{"title":"a","when":"evening@13pm"}}]`, diff --git a/cmd/things/importvalidate.go b/cmd/things/importvalidate.go index ba751521..b5013d2a 100644 --- a/cmd/things/importvalidate.go +++ b/cmd/things/importvalidate.go @@ -90,10 +90,11 @@ var importScheduleNumeric = regexp.MustCompile(`^\d`) // someday@18:00 in Someday, deadline 2026-13-01 on 1 Jan 2026 and deadline // someday as no deadline. So a value that starts with a digit must be a real // date, YYYY-MM-DD; a deadline has no time; and a when with an @ must be -// today, tomorrow, evening or a real YYYY-MM-DD before it, and a real time of -// day after it. Anything else, keywords, weekdays and English phrases such -// as next friday (measured: filed on that day), is passed through as `add` -// passes it. +// today, tomorrow, evening, a weekday name or a real YYYY-MM-DD before it, +// and a real time of day after it (a weekday name goes as its date, +// resolveImportWhens). Anything else, keywords, weekdays and English phrases +// such as next friday (measured: filed on that day), is passed through as +// `add` passes it. func checkScheduleValue(name, value string) string { v := strings.TrimSpace(value) normalize := things.NormalizeWhen @@ -136,13 +137,13 @@ func checkScheduleValue(name, value string) string { switch { case impossible != nil && impossible.Reason == things.WhenTimeIgnored: return "Things ignores a time after anytime or someday" - case things.TimedWhenKeyword(day): + case things.TimedWhenWord(day): case importScheduleDate.MatchString(day): if badDate { return "not a real date" } default: - return "before the @ must be today, tomorrow, evening or a date as YYYY-MM-DD" + return "before the @ must be today, tomorrow, evening, a weekday name such as friday or a date as YYYY-MM-DD" } if impossible != nil && impossible.Reason == things.WhenBadClock { return "not a real time of day after the @" @@ -271,7 +272,7 @@ const ( var refusalKinds = [numRefusalKinds]struct{ reason, header string }{ kindRepeating: {"repeating", ""}, kindDate: {"invalid-date", "Things rejects the whole payload over a creation-date or completion-date that is not a date and time with seconds and a UTC offset, such as 2026-10-05T10:30:00Z or 2026-10-05T10:30:00+02:00"}, - kindSchedule: {"invalid-date", "Things saves a when or deadline it cannot read as something else, with no warning (a when of 2026-13-01 or 18:00 lands in Today, a deadline of 2026-13-01 on 1 Jan 2026). A misspelt keyword, or a keyword as a deadline, is refused. A when or deadline that starts with a digit must be a date as YYYY-MM-DD, and a deadline has no time. A when with an @ must have today, tomorrow, evening or a YYYY-MM-DD date before it and a time of day such as 18:00 or 6pm after it"}, + kindSchedule: {"invalid-date", "Things saves a when or deadline it cannot read as something else, with no warning (a when of 2026-13-01 or 18:00 lands in Today, a deadline of 2026-13-01 on 1 Jan 2026). A misspelt keyword, or a keyword as a deadline, is refused. A when or deadline that starts with a digit must be a date as YYYY-MM-DD, and a deadline has no time. A when with an @ must have today, tomorrow, evening, a weekday name such as friday or a YYYY-MM-DD date before it and a time of day such as 18:00 or 6pm after it"}, kindFuture: {"future-date", "Things saves a creation-date or completion-date in the future as now, so the item would not get the date the payload gives. Give a date in the past, or leave the attribute out"}, kindType: {"invalid-type", "Things rejects the whole payload over an attribute of the wrong JSON type. title, notes, when, deadline, list, list-id, heading, heading-id, area and area-id must be strings, tags an array of strings, completed and canceled true or false, items and checklist-items arrays (each may be null), and attributes an object"}, kindShape: {"invalid-item", "Things rejects the whole payload over an item it does not take: one that is not an object, has no attributes, or whose type or operation it does not know or does not allow there, written exactly (to-do or project at the top level, to-do or heading in a project's items, checklist-item in a to-do's checklist-items; operation create or update)"}, diff --git a/cmd/things/importvalidate_test.go b/cmd/things/importvalidate_test.go index 5f7cfb73..9d1e04e0 100644 --- a/cmd/things/importvalidate_test.go +++ b/cmd/things/importvalidate_test.go @@ -11,7 +11,7 @@ func TestCheckScheduleValue(t *testing.T) { const ( badTime = "not a real time of day after the @" badDate = "not a real date" - badBefore = "before the @ must be today, tomorrow, evening or a date as YYYY-MM-DD" + badBefore = "before the @ must be today, tomorrow, evening, a weekday name such as friday or a date as YYYY-MM-DD" notDate = "not a date as YYYY-MM-DD, or a date and time as YYYY-MM-DD@HH:MM" ignored = "Things ignores a time after anytime or someday" ) @@ -51,7 +51,10 @@ func TestCheckScheduleValue(t *testing.T) { {"when", "2026-13-01@6pm", badDate}, {"when", "2026-10-10@25:00", badTime}, {"when", "next friday@18:00", badBefore}, - {"when", "friday@6pm", badBefore}, + // A weekday name goes as its date (resolveImportWhens). + {"when", "friday@6pm", ""}, + {"when", "Friday@25:00", badTime}, + {"when", "fri@6pm", badBefore}, // No @. {"when", "2026-10-10", ""}, {"when", "2026-1-5", notDate}, diff --git a/cmd/things/verify.go b/cmd/things/verify.go index bc4e0552..464227a7 100644 --- a/cmd/things/verify.go +++ b/cmd/things/verify.go @@ -673,11 +673,9 @@ 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)") - } + case whenUnplaced: + 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 aba406a5..00b7bb12 100644 --- a/cmd/things/when.go +++ b/cmd/things/when.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "strings" "time" "github.com/ryanlewis/things-cli/internal/db" @@ -32,6 +33,9 @@ const ( // tomorrow when it has passed. A date and time sets it on that date, or // today when the date is past. Either files it in the day part, not the // evening, when the day is today. +// - evening with a time (evening@18:00) files it this evening, today, and +// sets the reminder at that time, one already past included. Measured +// with things:///add on 10 Oct 2026. type whenPlace struct { start model.Start // the start of an item with no start date day model.ThingsDate @@ -50,61 +54,113 @@ func parseClock(s string) (int, bool) { return t.Hour()*60 + t.Minute(), true } -// placeWhen returns where value files an item when Things reads it at now. ok -// is false for a value whose outcome is not worked out here: an English -// phrase, a time other than HH:MM, a time within a minute of now, which lands -// today or tomorrow depending on when Things reads it, or a time within an -// hour of a daylight-saving change, where how Things reads the wall clock was -// not measured. -func placeWhen(value string, now time.Time) (place whenPlace, ok bool) { +// whenKind is the form of a --when value placeWhen works out. +type whenKind int + +const ( + whenWord whenKind = iota // a keyword, or an empty value + whenClock // HH:MM + whenDate // YYYY-MM-DD + whenDateClock // YYYY-MM-DD@HH:MM + whenEveningClock // evening@HH:MM +) + +// whenForm is a --when value, as sent, read into its parts: the keyword for +// whenWord, the date for whenDate and whenDateClock, and the time in minutes +// after midnight for the forms with one. +type whenForm struct { + kind whenKind + word string + date time.Time + clock int +} + +// parseWhen reads value, as sent, into the form placeWhen works out where it +// files an item. ok is false for any other value: an abbreviated weekday +// (fri), a word with a time after the @ that things.ResolveWhen did not +// rewrite, or an English phrase. The read-back checks only the forms parsed +// here, which is what whenPlaced reports. +func parseWhen(value string) (f whenForm, ok bool) { v, err := things.NormalizeWhen(value) if err != nil { - return whenPlace{}, false + return whenForm{}, false } - today := model.ThingsDateFromTime(now) switch v { - case "", "anytime": - return whenPlace{start: model.StartAnytime, reminder: reminderKept}, true - case "someday": - return whenPlace{start: model.StartSomeday, reminder: reminderKept}, true - case "today": - return whenPlace{day: today, bucket: 0, reminder: reminderNone}, true - case "evening": - return whenPlace{day: today, bucket: 1, reminder: reminderNone}, true - case "tomorrow": - return whenPlace{day: model.ThingsDateFromTime(now.AddDate(0, 0, 1)), bucket: -1, reminder: reminderKept}, true + case "", "anytime", "someday", "today", "evening", "tomorrow": + return whenForm{kind: whenWord, word: v}, true } if clock, ok := parseClock(v); ok { + return whenForm{kind: whenClock, clock: clock}, true + } + if day, clockPart, ok := strings.Cut(v, "@"); ok && strings.EqualFold(day, "evening") { + clock, ok := parseClock(clockPart) + return whenForm{kind: whenEveningClock, clock: clock}, ok + } + f = whenForm{kind: whenDate} + datePart := v + if len(v) == len("2006-01-02@15:04") && v[10] == '@' { + clock, ok := parseClock(v[11:]) + if !ok { + return whenForm{}, false + } + f = whenForm{kind: whenDateClock, clock: clock} + datePart = v[:10] + } + if f.date, err = time.ParseInLocation("2006-01-02", datePart, time.Local); err != nil { + return whenForm{}, false + } + return f, true +} + +// placeWhen returns where value files an item when Things reads it at now. ok +// is false for a value whose outcome is not worked out here: one parseWhen +// does not read, a time within a minute of now, which lands today or tomorrow +// depending on when Things reads it, or a time within an hour of a +// daylight-saving change, where how Things reads the wall clock was not +// measured. +func placeWhen(value string, now time.Time) (place whenPlace, ok bool) { + f, ok := parseWhen(value) + if !ok { + return whenPlace{}, false + } + today := model.ThingsDateFromTime(now) + switch f.kind { + case whenWord: + switch f.word { + case "", "anytime": + return whenPlace{start: model.StartAnytime, reminder: reminderKept}, true + case "someday": + return whenPlace{start: model.StartSomeday, reminder: reminderKept}, true + case "today": + return whenPlace{day: today, bucket: 0, reminder: reminderNone}, true + case "evening": + return whenPlace{day: today, bucket: 1, reminder: reminderNone}, true + } + return whenPlace{day: model.ThingsDateFromTime(now.AddDate(0, 0, 1)), bucket: -1, reminder: reminderKept}, true + case whenClock: if nearOffsetChange(now) { return whenPlace{}, false } - at := time.Date(now.Year(), now.Month(), now.Day(), clock/60, clock%60, 0, 0, time.Local) + at := time.Date(now.Year(), now.Month(), now.Day(), f.clock/60, f.clock%60, 0, 0, time.Local) switch { case at.Sub(now) > time.Minute: - return whenPlace{day: today, bucket: 0, reminder: clock}, true + return whenPlace{day: today, bucket: 0, reminder: f.clock}, true case now.Sub(at) > time.Minute: - return whenPlace{day: model.ThingsDateFromTime(now.AddDate(0, 0, 1)), bucket: -1, reminder: clock}, true + return whenPlace{day: model.ThingsDateFromTime(now.AddDate(0, 0, 1)), bucket: -1, reminder: f.clock}, true } return whenPlace{}, false - } - datePart, clockPart, timed := v, "", false - if len(v) == len("2006-01-02@15:04") && v[10] == '@' { - datePart, clockPart, timed = v[:10], v[11:], true - } - date, err := time.ParseInLocation("2006-01-02", datePart, time.Local) - if err != nil { - return whenPlace{}, false - } - day := model.ThingsDateFromTime(date) - if timed { - clock, ok := parseClock(clockPart) - if !ok { - return whenPlace{}, false - } + case whenEveningClock: + // Measured in Things 3 on 10 Oct 2026: evening with any time, + // one already past included, files the item this evening, today, + // with the reminder at that time. + return whenPlace{day: today, bucket: 1, reminder: f.clock}, true + } + day := model.ThingsDateFromTime(f.date) + if f.kind == whenDateClock { if day <= today { - return whenPlace{day: today, bucket: 0, reminder: clock}, true + return whenPlace{day: today, bucket: 0, reminder: f.clock}, true } - return whenPlace{day: day, bucket: -1, reminder: clock}, true + return whenPlace{day: day, bucket: -1, reminder: f.clock}, true } switch { case day < today: @@ -115,33 +171,12 @@ 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). +// whenPlaced reports whether value, as sent, is one the read-back checks +// (parseWhen). For any other value 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 + _, ok := parseWhen(value) + return ok } // whenUnchecked is the reason an add or edit gives for an item reported not @@ -455,6 +490,9 @@ const ( // whenNotUnderstood is an item sent a free phrase (whenPhrase) that // has no start date: Things did not understand the phrase. whenNotUnderstood + // whenUnplaced is an item sent a value the read-back cannot check + // (whenPlaced), which it reports not confirmed. + whenUnplaced ) // verdict judges t, a new item read at now, against c. add and import both @@ -466,6 +504,8 @@ func (c *whenCheck) verdict(t *model.Task, now time.Time) whenVerdict { return whenNotFiled case c != nil && whenPhrase(c.value) && t.StartDate == nil: return whenNotUnderstood + case c != nil && !whenPlaced(c.judged()): + return whenUnplaced } return whenFiled } diff --git a/cmd/things/when_test.go b/cmd/things/when_test.go index 5e4a293c..3ff2e53a 100644 --- a/cmd/things/when_test.go +++ b/cmd/things/when_test.go @@ -46,7 +46,13 @@ func TestPlaceWhen(t *testing.T) { {"2026-10-09@10:00", whenPlace{day: day(9), bucket: -1, reminder: 10 * 60}, true}, {"2026-10-09T10:00:00+01:00", whenPlace{day: day(9), bucket: -1, reminder: 10 * 60}, true}, {"next friday", whenPlace{}, false}, + // A weekday is placed once things.ResolveWhen sends it as a date. {"friday", whenPlace{}, false}, + {"friday@18:00", whenPlace{}, false}, + // evening with a time, as sent, measured on 10 Oct 2026. + {"evening@18:00", whenPlace{day: day(7), bucket: 1, reminder: 18 * 60}, true}, + {"evening@09:00", whenPlace{day: day(7), bucket: 1, reminder: 9 * 60}, true}, + {"evening@25:00", whenPlace{}, false}, // A bare 12-hour time is sent as HH:MM, so it is placed like one. {"6pm", whenPlace{day: day(7), bucket: 0, reminder: 18 * 60}, true}, {"6:30pm", whenPlace{day: day(7), bucket: 0, reminder: 18*60 + 30}, true}, diff --git a/docs/content/agents.md b/docs/content/agents.md index 7eabfb16..4194d898 100644 --- a/docs/content/agents.md +++ b/docs/content/agents.md @@ -523,9 +523,9 @@ instead of assuming: 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. + the item was created, but the CLI cannot check where a `--when` of an + abbreviated weekday (`fri`) or an English phrase filed it, so `things + show` it. `import` reports such a `when` the same way. `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. @@ -563,8 +563,8 @@ instead of assuming: 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. 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` + the CLI cannot place (an abbreviated weekday, 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. diff --git a/docs/content/commands.md b/docs/content/commands.md index 8f7bb426..8b7b0a08 100644 --- a/docs/content/commands.md +++ b/docs/content/commands.md @@ -564,8 +564,8 @@ token is `blank-title`, as `import` reports the same title. `edit --title`, `--when` accepts a keyword (`today`, `tomorrow`, `evening`, `anytime`, `someday`), a date `YYYY-MM-DD`, a time `HH:MM` or a 12-hour time (`21:30`, `9:30PM`, `6pm`), a date+time `YYYY-MM-DD@HH:MM`, an RFC3339 timestamp, -or an English phrase such as `friday` or `next monday`, which Things reads -itself. An RFC3339 timestamp keeps its wall-clock time and ignores its +a weekday name such as `friday`, alone or with a time (`friday@9pm`), or an +English phrase such as `next monday`, which Things reads itself. An RFC3339 timestamp keeps its wall-clock time and ignores its offset: `2026-05-01T09:00:00+05:00` sets a 09:00 reminder in your local time, not the local time of 09:00 at +05:00. `--deadline` accepts a `YYYY-MM-DD` date or an English phrase such as `next friday`; the `--when` @@ -578,8 +578,14 @@ or `tomorrow` with a time after the `@` is sent as the measured `2026-10-12@21:00`, and each is read back like a date and time. Today and tomorrow are the dates when the command runs, so `tomorrow@6pm` sent just before midnight is the next day, even if Things reads it after midnight. -`evening@6pm` and anything else with an `@` are sent as typed and not -checked. +A weekday name, in any case, is sent as the date Things gives it, the next +such day strictly after today, so on a Saturday `saturday` is next +Saturday: `friday` goes as that `YYYY-MM-DD`, and `friday@9pm` as that date +at `21:00`, whether or not the time has passed. Each is read back like a +date. `evening` with a time is sent as `evening@HH:MM` (`evening@6pm` as +`evening@18:00`), since a date and time today would file the item in the +day part, and is read back as this evening, today, any time included. +Abbreviations such as `fri` are sent as typed and not checked. Things files the to-do by matching `--list` (or `--project`) against the titles of the projects and areas it shows, and `--heading` against the @@ -794,9 +800,8 @@ 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"`. 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` +cannot place, an abbreviated weekday (`fri`) or an English phrase that +Things understood, 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": @@ -1003,16 +1008,16 @@ deadline are refused. The Things JSON format is narrower than the URL `add` uses: its documentation names only the keywords, a date and a date and time. So a value that starts with a digit must be a real date, `YYYY-MM-DD`, and a `deadline` has no time. A `when` with an `@` must -have `today`, `tomorrow`, `evening` or a real `YYYY-MM-DD` before it and a +have `today`, `tomorrow`, `evening`, a weekday name or a real `YYYY-MM-DD` before it and a real time of day, such as `18:00`, `9:30PM` or `6pm`, after it: Things files `tomorrow@25:00` tomorrow with no reminder, and ignores the time in `someday@18:00` and `anytime@…`, so these are refused, as is a phrase -with a time such as `next friday@18:00`. A date, `today` or `tomorrow` -with a time is sent as `add` sends it, as `YYYY-MM-DD@HH:MM`: `today@6pm` -goes as today's date at `18:00`. A bare time and an RFC 3339 -timestamp are refused. The keywords, weekday names -and English phrases such as `next week` are sent as they are, as `add` -sends them; how Things reads a phrase in a payload was not measured. Update +with a time such as `next friday@18:00`. A date, `today`, `tomorrow` or a +weekday name with a time is sent as `add` sends it, as `YYYY-MM-DD@HH:MM`: +`today@6pm` goes as today's date at `18:00`. A weekday name alone goes as +its date, and `evening` with a time as `evening@HH:MM`. A bare time and an +RFC 3339 timestamp are refused. The keywords and English phrases such as +`next week` are sent as they are, as `add` sends them; how Things reads a phrase in a payload was not measured. Update items are checked too. Things also rejects the whole payload, showing an error and creating @@ -1186,8 +1191,10 @@ an unconfirmed `add` does, and no `uuid` unless the reason is payload completes or cancels, or one inside a project it completes or cancels, is not checked against its `when`. - `when`: the item's `when` is a free phrase Things did not understand, so - the new item has no start date. It carries its `uuid` and `landed`, and - the import exits 0, as `add` does with such a `--when`. + the new item has no start date, or one the CLI cannot place, such as + `fri` or `next week`, so where it filed the item is not checked (the line + says `Created, not confirmed (when not checked)`). It carries its `uuid` + and `landed`, and the import exits 0, as `add` does with such a `--when`. The first four, and `when`, exit 0 with the list printed. Any `not-found` item, or a `shares-dated-title` item with too few new items to account for it, makes diff --git a/internal/skill/SKILL.md b/internal/skill/SKILL.md index f93d1418..49674088 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -151,7 +151,7 @@ The rest of that import is already applied — re-run with only the failed items `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.** +`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, or one the CLI cannot place, such as `fri`, so it is not checked; 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`, a weekday name or a real date before it and a real time (`18:00`, `6pm`) after it (a date, `today`, `tomorrow` or a weekday name with a time is sent as `YYYY-MM-DD@HH:MM`, a weekday alone as its date and `evening` with a time as `evening@HH:MM`, as `add` sends them, 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.** `--no-verify` skips this read-back and the tag one in rule 1; it does **not** skip rule 2, which is a documented rule rather than a guess about what Things did. @@ -364,7 +364,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. 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. +`--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 `evening@18:00` and read back as this evening), a weekday name (see below), 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 (`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 weekday name, in any case and alone or with a time (`friday`, `Friday@9pm`), is sent as the date Things gives it, the next such day strictly after today (on a Saturday, `saturday` is next Saturday), as `YYYY-MM-DD` or `YYYY-MM-DD@HH:MM`, and read back like a date. `evening` with a time is sent as `evening@HH:MM` and read back as this evening, today, any time included. A value the CLI cannot place — an abbreviated weekday (`fri`) 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. diff --git a/internal/things/dates.go b/internal/things/dates.go index 541863ed..84d78ded 100644 --- a/internal/things/dates.go +++ b/internal/things/dates.go @@ -15,10 +15,9 @@ var whenKeywords = []string{"today", "tomorrow", "evening", "anytime", "someday" // allowlisted so the typo detector never mistakes them for a keyword — e.g. // "monday" is Levenshtein distance 2 from "today" and would otherwise be // rejected as a typo. Abbreviations are included for the same reason. -var weekdayWords = []string{ - "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", +var weekdayWords = append(slices.Clone(weekdayNames), "mon", "tue", "tues", "wed", "thu", "thur", "thurs", "fri", "sat", "sun", -} +) func isWhenKeyword(s string) bool { return slices.Contains(whenKeywords, s) @@ -180,23 +179,31 @@ func clock24(v string) (string, bool) { // and tomorrow become their dates at now, and the time becomes HH:MM (6pm is // 18:00). How Things reads a keyword or a 12-hour time after an @ was not // measured. So tomorrow sent just before midnight is the day after the one -// it was sent on, whenever Things reads it. Any other value is returned as it -// is: evening with a time too, since a date and time today files the item in -// the day part, not the evening, and one NormalizeWhen refuses. +// it was sent on, whenever Things reads it. +// +// A weekday name (weekdayDate), alone or with a time, is rewritten to the +// date Things gives it, YYYY-MM-DD or YYYY-MM-DD@HH:MM. evening with a time +// is sent as evening@HH:MM: a date and time today files the item in the day +// part, not the evening. Any other value is returned as it is, and so is one +// NormalizeWhen refuses. func ResolveWhen(v string, now time.Time) string { n, err := NormalizeWhen(v) if err != nil { return v } + now = now.In(time.Local) day, clock, timed := strings.Cut(n, "@") + wd, weekday := weekdayName(day) if !timed { + if weekday { + return weekdayDate(now, wd).Format("2006-01-02") + } return v } c, ok := clockParts(clock) if !ok || !c.real() { return v } - now = now.In(time.Local) var date time.Time switch m := whenDateShape.FindStringSubmatch(day); { case m != nil && m[4] == "": @@ -208,21 +215,55 @@ func ResolveWhen(v string, now time.Time) string { date = now case strings.EqualFold(day, "tomorrow"): date = now.AddDate(0, 0, 1) + case strings.EqualFold(day, "evening"): + return "evening@" + c.hhmm() + case weekday: + date = weekdayDate(now, wd) default: return v } return date.Format("2006-01-02") + "@" + c.hhmm() } +// weekdayNames are the day names ResolveWhen rewrites to a date, in +// time.Weekday order. Abbreviations (fri) were not measured, so they go to +// Things as typed. +var weekdayNames = []string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"} + +// weekdayName reports the day v, in any case, names, if it is one of +// weekdayNames. +func weekdayName(v string) (time.Weekday, bool) { + i := slices.Index(weekdayNames, strings.ToLower(v)) + return time.Weekday(i), i >= 0 +} + +// weekdayDate is the date Things gives a weekday name sent at now: the next +// such day strictly after today, so the name of today is a week on. +// Measured in Things 3 on Saturday 10 Oct 2026: friday, friday@9pm and +// monday@9pm went to 16 and 12 Oct, and saturday, saturday@9am and +// saturday@9pm to 17 Oct, the time only setting the reminder. +func weekdayDate(now time.Time, wd time.Weekday) time.Time { + days := (int(wd)-int(now.Weekday())+6)%7 + 1 + return time.Date(now.Year(), now.Month(), now.Day()+days, 12, 0, 0, 0, time.Local) +} + +// TimedWhenWord reports whether v, in any case, is a word --when takes a +// time after: a TimedWhenKeyword or a weekday name ResolveWhen rewrites. +func TimedWhenWord(v string) bool { + _, weekday := weekdayName(v) + return weekday || TimedWhenKeyword(v) +} + // WhenDateOrTime reports whether v, a value NormalizeWhen returned, is a -// date, a time of day, or a date or keyword with a time after an @ -// (checkWhenShape's shapes), as opposed to a keyword alone or a free phrase. +// date, a time of day, or a date, keyword or weekday name with a time after +// an @ (checkWhenShape's shapes), as opposed to a keyword alone or a free +// phrase. func WhenDateOrTime(v string) bool { day, clock, timed := strings.Cut(v, "@") if timed { shaped, _ := clockShape(clock) m := whenDateShape.FindStringSubmatch(day) - return shaped && (m != nil && m[4] == "" || TimedWhenKeyword(day)) + return shaped && (m != nil && m[4] == "" || TimedWhenWord(day)) } if m := whenDateShape.FindStringSubmatch(v); m != nil { return m[4] == "" @@ -265,8 +306,9 @@ func impossibleWhen(reason WhenReason, format string, args ...any) error { // checkWhenShape refuses a value shaped like a date or a time of day that // names none: a month or day that does not exist, an hour or minute out of -// range, a date followed by T that is not an ISO 8601 date-time, or a date -// or a keyword with something after the @ that is not a time of day. +// range, a date followed by T that is not an ISO 8601 date-time, or a date, +// a keyword or a weekday name (TimedWhenWord) with something after the @ +// that is not a time of day. // Measured in Things 3 on 9 Oct 2026, Things saves such a value as something // else with no warning (2026-13-01 lands in Today, tomorrow@25:00 lands // tomorrow with no reminder, someday@18:00 in Someday). Any other value @@ -299,7 +341,7 @@ func checkWhenShape(v string) error { switch low := strings.ToLower(day); { case low == "anytime" || low == "someday": return impossibleWhen(WhenTimeIgnored, "invalid --when value %q: Things ignores a time after %s", v, low) - case !TimedWhenKeyword(low): + case !TimedWhenWord(low): // Something else before the @: a phrase. return nil } diff --git a/internal/things/dates_test.go b/internal/things/dates_test.go index 70e8cffd..7a1a2b21 100644 --- a/internal/things/dates_test.go +++ b/internal/things/dates_test.go @@ -172,7 +172,7 @@ func TestNormalizeDeadlineRejects(t *testing.T) { // so add and edit give the same error before anything is sent. Phrases and // valid dates and times still pass. func TestNormalizeWhenRejectsImpossibleDatesAndTimes(t *testing.T) { - for _, in := range []string{"2026-13-01", "2026-00-10", "2026-02-30", "2026-04-31", "2026-13-01@09:00", "2026-05-01@25:00", "2026-05-01@09:60", "25:00", "9:75", "13:30pm", "0:30am", "2026-10-09T25:00:00Z", "2026-10-09T10:00:00+200", "2026-10-09T25:00", "tomorrow@25:00", "today@18:60", "Evening@13pm", "today@noon", "2026-10-09@soon", "someday@18:00", "anytime@9am", "today@6 pm", "today@9:30 pm", "9:30 pm"} { + for _, in := range []string{"2026-13-01", "2026-00-10", "2026-02-30", "2026-04-31", "2026-13-01@09:00", "2026-05-01@25:00", "2026-05-01@09:60", "25:00", "9:75", "13:30pm", "0:30am", "2026-10-09T25:00:00Z", "2026-10-09T10:00:00+200", "2026-10-09T25:00", "tomorrow@25:00", "today@18:60", "Evening@13pm", "friday@25:00", "Monday@noon", "today@noon", "2026-10-09@soon", "someday@18:00", "anytime@9am", "today@6 pm", "today@9:30 pm", "9:30 pm"} { if _, err := NormalizeWhen(in); err == nil || !strings.Contains(err.Error(), "invalid --when value") { t.Errorf("NormalizeWhen(%q) = %v, want it refused", in, err) } @@ -244,9 +244,10 @@ func TestKnownWhenWord(t *testing.T) { } } -// A date, today or tomorrow with a time after the @ is sent as -// YYYY-MM-DD@HH:MM, with today and tomorrow read at the instant it is sent. -// Anything else goes as typed. +// A date, today, tomorrow or a weekday name with a time after the @ is sent +// as YYYY-MM-DD@HH:MM, and a weekday name alone as YYYY-MM-DD, read at the +// instant it is sent. evening with a time is sent as evening@HH:MM. Anything +// else goes as typed. func TestResolveWhen(t *testing.T) { now := time.Date(2026, 10, 10, 14, 0, 0, 0, time.Local) beforeMidnight := time.Date(2026, 10, 10, 23, 59, 59, 0, time.Local) @@ -268,9 +269,23 @@ func TestResolveWhen(t *testing.T) { {"2026-1-5@6:30am", now, "2026-01-05@06:30"}, {"2026-10-12@09:30", now, "2026-10-12@09:30"}, {"2026-10-09T10:00:00+05:00", now, "2026-10-09@10:00"}, + // evening keeps its word, so the item stays in the evening. + {"evening@6pm", now, "evening@18:00"}, + {"Evening@9am", now, "evening@09:00"}, + // A weekday is the next such day strictly after today (a Saturday), + // whether or not the time has passed. + {"friday@9pm", now, "2026-10-16@21:00"}, + {"friday", now, "2026-10-16"}, + {"Monday@9pm", now, "2026-10-12@21:00"}, + {"sunday@9am", now, "2026-10-11@09:00"}, + {"saturday", now, "2026-10-17"}, + {"saturday@9am", now, "2026-10-17@09:00"}, + {"saturday@9pm", now, "2026-10-17@21:00"}, + {"sunday", beforeMidnight, "2026-10-11"}, // Not resolved. - {"evening@6pm", now, "evening@6pm"}, - {"friday@9pm", now, "friday@9pm"}, + {"fri@9pm", now, "fri@9pm"}, + {"fri", now, "fri"}, + {"friday@25:00", now, "friday@25:00"}, {"next friday@noon", now, "next friday@noon"}, {"today", now, "today"}, {"6pm", now, "6pm"},