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
99 changes: 75 additions & 24 deletions cmd/things/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ func (c *EditCmd) Run(d *Deps) error {

// checkOwn reports whether any of this command's own field flags may change
// the task. None of them is in coveredFields; runEdit adds the shared ones.
// Every field flag belongs either here, in commonEditFlags.uncoveredSet, or in
// covered, so a new one cannot be missed by changesFields and still pass
// certainNoOp. A move Things will drop (checkMove) does not count.
// Every field flag belongs either here or in commonEditFlags.covered, so a
// new one cannot be missed by changesFields and still pass certainNoOp. A
// move Things will drop (checkMove) does not count.
func (c *EditCmd) checkOwn(d *Deps, database *db.DB, task *model.Task) bool {
return c.checkMove(d, database, task) || c.checklistSet()
}
Expand Down Expand Up @@ -258,7 +258,7 @@ func runEdit(d *Deps, ref string, kind editKind, f *commonEditFlags, s *editStat
if err != nil {
return err
}
ownSet := checkOwn(d, database, task)
uncovered := checkOwn(d, database, task)

token := authToken(d, database)
update := func() error {
Expand All @@ -279,17 +279,22 @@ func runEdit(d *Deps, ref string, kind editKind, f *commonEditFlags, s *editStat
Reveal: s.Reveal,
})
}
uncovered := ownSet || f.uncoveredSet()
changed := f.changesFields(uncovered) && !f.certainNoOp(task, uncovered, foldTags(unknown))
// A reminder that cannot be read counts as one, so the edit waits.
hasReminder := func() bool {
ok, err := database.HasReminder(task.UUID)
return ok || err != nil
}
changed := f.changesFields(uncovered) && !f.certainNoOp(task, uncovered, foldTags(unknown), hasReminder)
return applyEdit(d, database, task, changed, checklist, s.Complete, s.Cancel, s.Duplicate, update)
}

// certainNoOp reports whether every field flag set on the edit provably
// leaves the item as it is, so there is no modification to wait for.
// uncovered says whether any field flag outside coveredFields is set, and
// dropped holds the folded tag names Things will drop (foldTags).
func (f *commonEditFlags) certainNoOp(task *model.Task, uncovered bool, dropped map[string]struct{}) bool {
return !uncovered && f.covered().unchanged(task, dropped)
// uncovered says whether any field flag outside coveredFields is set,
// dropped holds the folded tag names Things will drop (foldTags), and
// hasReminder reads whether the item has a reminder (whenUnchanged).
func (f *commonEditFlags) certainNoOp(task *model.Task, uncovered bool, dropped map[string]struct{}, hasReminder func() bool) bool {
return !uncovered && f.covered().unchanged(task, dropped, hasReminder)
}

// foldTags folds the names verifyTags says Things will drop, so the no-op
Expand All @@ -312,41 +317,42 @@ func (f *commonEditFlags) changesFields(uncovered bool) bool {
}

func (f *commonEditFlags) covered() coveredFields {
return coveredFields{f.Title, f.Notes, f.Deadline, f.Tags, f.AddTags}
}

// uncoveredSet reports whether any shared field flag outside coveredFields is
// set. runEdit adds the command's own flags (ownFieldsSet).
func (f *commonEditFlags) uncoveredSet() bool {
return anySet(f.PrependNotes, f.AppendNotes, f.When)
return coveredFields{f.Title, f.Notes, f.PrependNotes, f.AppendNotes, f.When, f.Deadline, f.Tags, f.AddTags}
}

// coveredFields are the edit flags whose effect can be predicted exactly from
// the item as read, so an edit made only of values it already has is caught
// before the read-back. Things records no change for such an edit, and
// waiting for one would end in a false "did not apply" after the full budget.
type coveredFields struct {
title, notes, deadline, tags, addTags *string
title, notes, prependNotes, appendNotes, when, deadline, tags, addTags *string
}

// set reports whether any covered flag was given.
func (f coveredFields) set() bool {
return anySet(f.title, f.notes, f.deadline, f.tags, f.addTags)
return anySet(f.title, f.notes, f.prependNotes, f.appendNotes, f.when, f.deadline, f.tags, f.addTags)
}

// unchanged reports whether each flag that is set already matches task. It is
// deliberately narrow: a value whose outcome depends on how Things reads it
// counts as a change, and the edit waits for its read-back as before. --when
// is left out for that reason — even `today` on an item already in Today may
// touch its reminder, which the CLI does not read. Tags named in dropped do
// not exist in Things, which ignores them, so they count as no change.
func (f coveredFields) unchanged(task *model.Task, dropped map[string]struct{}) bool {
// counts as a change, and the edit waits for its read-back as before. Tags
// named in dropped do not exist in Things, which ignores them, so they count
// as no change.
func (f coveredFields) unchanged(task *model.Task, dropped map[string]struct{}, hasReminder func() bool) bool {
if f.title != nil && *f.title != task.Title {
return false
}
if f.notes != nil && *f.notes != task.Notes {
return false
}
// Checked in Things 3: an empty append or prepend leaves the notes as
// they are.
if f.prependNotes != nil && *f.prependNotes != "" || f.appendNotes != nil && *f.appendNotes != "" {
return false
}
if f.when != nil && !whenUnchanged(*f.when, task, time.Now(), hasReminder) {
return false
}
// Tags compare the way Things matches them: case-insensitively, after
// trimming (db.FoldTag).
have := make(map[string]struct{}, len(task.Tags))
Expand Down Expand Up @@ -397,6 +403,51 @@ func deadlineUnchanged(value string, current *model.ThingsDate) bool {
return err == nil && current != nil && *current == model.ThingsDateFromTime(date)
}

// whenUnchanged covers a --when that leaves the item where it is, as checked
// in Things 3 on to-dos and projects. anytime, and an empty value, match an
// Anytime item with no start date; someday matches a Someday item with no
// start date. today and evening match an item scheduled for today in that part
// of the day, and a date of today matches either part. Each of those clears a
// reminder, which counts as a change, so hasReminder is asked only then.
// tomorrow, or a later date, matches an item scheduled that day, reminder or
// not. A time, a phrase, or a date already past counts as a change.
func whenUnchanged(value string, task *model.Task, now time.Time, hasReminder func() bool) bool {
v, err := things.NormalizeWhen(value)
if err != nil {
return false
}
switch v {
case "", "anytime":
return task.Start == model.StartAnytime && task.StartDate == nil
case "someday":
return task.Start == model.StartSomeday && task.StartDate == nil
case "tomorrow":
v = now.AddDate(0, 0, 1).Format("2006-01-02")
}
today := model.ThingsDateFromTime(now)
if task.StartDate == nil {
return false
}
switch v {
case "today":
return *task.StartDate == today && task.StartBucket == 0 && !hasReminder()
case "evening":
return *task.StartDate == today && task.StartBucket == 1 && !hasReminder()
}
date, err := time.ParseInLocation("2006-01-02", v, time.Local)
if err != nil {
return false
}
want := model.ThingsDateFromTime(date)
switch {
case want < today:
return false
case want == today:
return *task.StartDate == today && !hasReminder()
}
return *task.StartDate == want
}

// anySet reports whether any of the optional flags was given.
func anySet(flags ...*string) bool {
for _, f := range flags {
Expand Down
46 changes: 41 additions & 5 deletions cmd/things/edit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,10 @@ func TestEditCertainNoOpSkipsTheWait(t *testing.T) {
now := time.Now()
today := int(model.ThingsDateFromTime(now))
deadline := int(model.ThingsDateFromTime(time.Date(2026, 10, 15, 0, 0, 0, 0, time.Local)))
day := func(offset int) string { return now.AddDate(0, 0, offset).Format("2006-01-02") }
dayInt := func(offset int) string {
return strconv.Itoa(int(model.ThingsDateFromTime(now.AddDate(0, 0, offset))))
}

cases := []struct {
name string
Expand All @@ -355,15 +359,40 @@ func TestEditCertainNoOpSkipsTheWait(t *testing.T) {
{"newDeadline", []string{"edit", "one-1", "--deadline", "2026-10-16"}, false},
{"clearDeadline", []string{"edit", "one-1", "--deadline", ""}, false},
{"clearAbsentDeadline", []string{"edit", "two-1", "--deadline", ""}, true},
// --when always waits: even `today` on an item already in Today may
// touch its reminder, which the CLI does not read.
{"whenTodayOnToday", []string{"edit", "one-1", "--when", "today"}, false},
{"emptyAppendNotes", []string{"edit", "one-1", "--append-notes", ""}, true},
{"emptyPrependNotes", []string{"edit", "one-1", "--prepend-notes", ""}, true},
{"prependNotes", []string{"edit", "one-1", "--prepend-notes", "x"}, false},
{"whenTodayOnToday", []string{"edit", "one-1", "--when", "Today"}, true},
{"whenTodayDateOnToday", []string{"edit", "one-1", "--when", day(0)}, true},
{"whenEveningOnToday", []string{"edit", "one-1", "--when", "evening"}, false},
{"whenEveningOnEvening", []string{"edit", "eve-1", "--when", "evening"}, true},
{"whenTodayDateOnEvening", []string{"edit", "eve-1", "--when", day(0)}, true},
{"whenTodayOnEvening", []string{"edit", "eve-1", "--when", "today"}, false},
// Things clears a reminder on a --when for today without a time.
{"whenTodayClearsReminder", []string{"edit", "rem-1", "--when", "today"}, false},
{"whenTodayDateClearsReminder", []string{"edit", "rem-1", "--when", day(0)}, false},
{"whenTime", []string{"edit", "rem-1", "--when", "18:00"}, false},
{"whenDateTime", []string{"edit", "tom-1", "--when", day(1) + "@08:00"}, false},
{"whenTomorrowKeepsReminder", []string{"edit", "tom-1", "--when", "tomorrow"}, true},
{"whenTomorrowDate", []string{"edit", "tom-1", "--when", day(1)}, true},
{"whenOtherDate", []string{"edit", "tom-1", "--when", day(2)}, false},
{"whenPastDate", []string{"edit", "past-1", "--when", day(-1)}, false},
{"whenSomedayOnSomeday", []string{"edit", "some-1", "--when", "someday"}, true},
{"whenSomedayOnScheduled", []string{"edit", "tom-1", "--when", "someday"}, false},
{"whenAnytimeOnAnytime", []string{"edit", "two-1", "--when", "anytime"}, true},
{"whenClearOnAnytime", []string{"edit", "two-1", "--when", ""}, true},
{"whenClearOnSomeday", []string{"edit", "some-1", "--when", ""}, false},
{"whenClearOnInbox", []string{"edit", "inbox-1", "--when", ""}, false},
{"whenAnytimeOnToday", []string{"edit", "one-1", "--when", "anytime"}, false},
{"whenPhrase", []string{"edit", "one-1", "--when", "friday"}, false},
{"allNoOp", []string{"edit", "one-1", "--title", "Post letter", "--notes", "second class", "--add-tags", "errand", "--deadline", "2026-10-15"}, true},
{"noOpPlusWhen", []string{"edit", "one-1", "--title", "Post letter", "--when", "today"}, false},
{"allNoOp", []string{"edit", "one-1", "--title", "Post letter", "--notes", "second class", "--add-tags", "errand", "--deadline", "2026-10-15", "--when", "today", "--append-notes", ""}, true},
{"noOpPlusNewWhen", []string{"edit", "one-1", "--title", "Post letter", "--when", "someday"}, false},
{"sameWhenPlusNewTitle", []string{"edit", "one-1", "--title", "Post the letter", "--when", "today"}, false},
{"mixed", []string{"edit", "one-1", "--title", "Post letter", "--notes", "first class"}, false},
{"mixedUncoveredFlag", []string{"edit", "one-1", "--title", "Post letter", "--append-notes", "x"}, false},
{"projectSameTitle", []string{"project", "edit", "repproj-1", "--title", "Weekly review"}, true},
{"projectWhenSomeday", []string{"project", "edit", "proj-1", "--when", "someday", "--append-notes", ""}, true},
{"projectWhenToday", []string{"project", "edit", "proj-1", "--when", "today"}, false},
{"projectMoveArea", []string{"project", "edit", "repproj-1", "--title", "Weekly review", "--area", "Home"}, false},
}
for _, tc := range cases {
Expand All @@ -376,6 +405,13 @@ func TestEditCertainNoOpSkipsTheWait(t *testing.T) {
`INSERT INTO TMTag (uuid, title) VALUES ('tag-1', 'Errand'), ('tag-2', 'Urgent'), ('tag-3', 'Home')`,
`INSERT INTO TMTaskTag (tasks, tags) VALUES ('one-1', 'tag-1'), ('one-1', 'tag-2')`,
`INSERT INTO TMTask (uuid, title, type, status, trashed, start) VALUES ('two-1', 'Undated', 0, 0, 0, 1)`,
`INSERT INTO TMTask (uuid, title, type, status, trashed, start, startDate, startBucket) VALUES ('eve-1', 'Evening', 0, 0, 0, 1, ` + strconv.Itoa(today) + `, 1)`,
`INSERT INTO TMTask (uuid, title, type, status, trashed, start, startDate, startBucket, reminderTime) VALUES ('rem-1', 'Reminded', 0, 0, 0, 1, ` + strconv.Itoa(today) + `, 0, 1207959552)`,
`INSERT INTO TMTask (uuid, title, type, status, trashed, start, startDate, startBucket, reminderTime) VALUES ('tom-1', 'Tomorrow', 0, 0, 0, 2, ` + dayInt(1) + `, 0, 536870912)`,
`INSERT INTO TMTask (uuid, title, type, status, trashed, start, startDate, startBucket) VALUES ('past-1', 'Overdue start', 0, 0, 0, 1, ` + dayInt(-1) + `, 0)`,
`INSERT INTO TMTask (uuid, title, type, status, trashed, start) VALUES ('some-1', 'Someday', 0, 0, 0, 2)`,
`INSERT INTO TMTask (uuid, title, type, status, trashed, start) VALUES ('inbox-1', 'Inbox', 0, 0, 0, 0)`,
`INSERT INTO TMTask (uuid, title, type, status, trashed, start) VALUES ('proj-1', 'Someday project', 1, 0, 0, 2)`,
`INSERT INTO TMArea (uuid, title, "index") VALUES ('area-1', 'Home', 1)`,
}
for _, s := range stmts {
Expand Down
7 changes: 5 additions & 2 deletions docs/content/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,8 +434,11 @@ instead of assuming:
- **Edits are read back.** `edit` and `project edit` wait for Things to
record the change, then print the item as `things show` would. Exit 0
with the item printed is the confirmation, so the agent does not need a
second `things show`. With `--no-verify` or `--duplicate` nothing is
read back and the output says the edit is unconfirmed.
second `things show`. An edit that re-sets values the item already has,
such as `--when someday` on a Someday item, prints the item at once, so
re-running a plan does not fail on work already done. 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.
The CLI warns before writing; `--create-tags` creates the missing ones
first and `--strict-tags` refuses to write instead.
Expand Down
19 changes: 12 additions & 7 deletions docs/content/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,13 +515,18 @@ to `add`, `project add`, `complete`, `cancel`, `tag add` and the new
items and status changes in an `import`.
An edit that sets every field to the value
it already has is detected before the wait when every flag is `--title`,
`--notes`, `--tags`, `--add-tags` (tags compared case-insensitively) or a
`--deadline` date. Tags that do not exist in Things count as no change,
since Things drops them, unless `--create-tags` creates them first. So
does a move the warnings above say will leave the item where it is. It
then prints the item straight away. Any other
re-set value, `--when` included, still waits and reports the same error,
so on that error check the item with `things show` before retrying.
`--notes`, `--tags`, `--add-tags` (tags compared case-insensitively), a
`--deadline` date, an empty `--append-notes` or `--prepend-notes`, or a
`--when` of `anytime`, `someday`, `today`, `evening`, `tomorrow`, empty,
or a date from today on. `--when today`, `evening` or today's date clears
a reminder, so on an item with one it counts as a change and waits; a
later day keeps the reminder. Tags that do not exist in Things count as
no change, since Things drops them, unless `--create-tags` creates them
first. So does a move the warnings above say will leave the item where it
is. It then prints the item straight away. Any other re-set value, such as
a `--when` with a time or an English phrase, still waits and reports the
same error, so on that error check the item with `things show` before
retrying.
An edit with nothing to change (no field flags, or `--complete` on a task
that is already complete) prints the item without waiting.

Expand Down
1 change: 1 addition & 0 deletions internal/db/dbtest/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ CREATE TABLE TMTask (
start INTEGER,
startDate INTEGER,
startBucket INTEGER,
reminderTime INTEGER,
deadline INTEGER,
deadlineSuppressionDate INTEGER,
"index" INTEGER,
Expand Down
11 changes: 11 additions & 0 deletions internal/db/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,17 @@ func (d *DB) GetTaskByUUID(uuid string) (*model.Task, error) {
return &t, nil
}

// HasReminder reports whether the item has a reminder time set. Things clears
// it when a --when for today arrives without a time, so the edit no-op check
// needs it; nothing prints it.
func (d *DB) HasReminder(uuid string) (bool, error) {
var n int
if err := d.db.QueryRow(`SELECT COUNT(*) FROM TMTask WHERE uuid = ? AND reminderTime IS NOT NULL`, uuid).Scan(&n); err != nil {
return false, fmt.Errorf("reading reminder: %w", err)
}
return n > 0, nil
}

// uuidChunkSize caps how many uuids go into one IN (...) clause, so a caller
// passing an arbitrarily long list can never trip SQLITE_MAX_VARIABLE_NUMBER —
// the bound parameter limit is a property of the SQLite build, not something
Expand Down
19 changes: 19 additions & 0 deletions internal/db/tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4744,3 +4744,22 @@ func TestListTasksProjectChildrenStayInOtherViews(t *testing.T) {
t.Errorf("anytime: sd-anytime-child should still list, got %v", uuidsOf(got))
}
}

// HasReminder reads reminderTime alone; a missing item has none.
func TestHasReminder(t *testing.T) {
d, fx := newFixture(t)
fx.Todo("rem-1", "Reminded", 1)
fx.Todo("plain-1", "Plain", 2)
if _, err := d.db.Exec(`UPDATE TMTask SET reminderTime = 1207959552 WHERE uuid = 'rem-1'`); err != nil {
t.Fatalf("seed: %v", err)
}
for uuid, want := range map[string]bool{"rem-1": true, "plain-1": false, "nope": false} {
got, err := d.HasReminder(uuid)
if err != nil {
t.Fatalf("HasReminder(%q): %v", uuid, err)
}
if got != want {
t.Errorf("HasReminder(%q) = %v, want %v", uuid, got, want)
}
}
}
Loading
Loading