Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 96 additions & 9 deletions cmd/things/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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} {
Expand Down Expand Up @@ -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)
}
})
}
}
}
7 changes: 5 additions & 2 deletions cmd/things/import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]}}]`,
Expand Down
7 changes: 7 additions & 0 deletions cmd/things/importcreate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
} {
Expand Down
32 changes: 26 additions & 6 deletions cmd/things/importreadback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/things/importshape_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}]`,
Expand Down
15 changes: 8 additions & 7 deletions cmd/things/importvalidate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 @"
Expand Down Expand Up @@ -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)"},
Expand Down
7 changes: 5 additions & 2 deletions cmd/things/importvalidate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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},
Expand Down
Loading
Loading