From 28171487c5705ef7c99c55ece556149d20aedcec Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sat, 10 Oct 2026 15:00:40 +0100 Subject: [PATCH 1/2] fix(import): send a dated when time as YYYY-MM-DD@HH:MM and read it back as sent --- cmd/things/import.go | 66 ++++++++++++++++++++++++++++++++- cmd/things/import_test.go | 51 +++++++++++++++++++++++++ cmd/things/importcreate_test.go | 6 +++ cmd/things/importreadback.go | 26 +++++++------ cmd/things/when.go | 3 +- docs/content/commands.md | 7 +++- internal/skill/SKILL.md | 2 +- 7 files changed, 142 insertions(+), 19 deletions(-) diff --git a/cmd/things/import.go b/cmd/things/import.go index dd8cc0ca..ca6afda7 100644 --- a/cmd/things/import.go +++ b/cmd/things/import.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "time" "github.com/ryanlewis/things-cli/internal/things" ) @@ -59,8 +60,8 @@ func (c *ImportCmd) Run(d *Deps) error { resolveImportDests(d, database, plan.creates) // A token read error is only a warning: the payload may be create-only. token := authToken(d, database) - return applyImport(d, database, plan, func() error { - return things.ImportJSON(string(data), token, c.Reveal) + return applyImport(d, database, plan, func(sent time.Time) error { + return things.ImportJSON(string(resolveImportWhens(data, sent)), token, c.Reveal) }) } @@ -91,6 +92,67 @@ func decodeImportJSON(data []byte) ([]any, error) { return items, nil } +// resolveImportWhens returns data, a payload decodeImportJSON accepted, with +// each when in an item's attributes rewritten as things.ResolveWhen sends it +// at now, as add, project add and edit send --when: today@6pm becomes today's +// date at 18:00. The rest of the payload goes byte for byte as given. It +// walks the tokens as importDuplicateKeys does. +func resolveImportWhens(data []byte, now time.Time) []byte { + dec := json.NewDecoder(bytes.NewReader(data)) + var out []byte + copied := 0 + // walk reads one value: the value of key in an object that is itself + // the value of in ("" for both in an array or at the top level). + var walk func(in, key string) error + walk = func(in, key string) error { + start := dec.InputOffset() + tok, err := dec.Token() + if err != nil { + return err + } + switch v := tok.(type) { + case string: + if key != "when" || in != "attributes" { + return nil + } + sent := things.ResolveWhen(v, now) + if sent == v { + return nil + } + // start is the end of the key; the value follows its colon. + for start < int64(len(data)) && data[start] != '"' { + start++ + } + quoted, _ := json.Marshal(sent) + out = append(append(out, data[copied:start]...), quoted...) + copied = int(dec.InputOffset()) + case json.Delim: + for dec.More() { + in, name := "", "" + if v == '{' { + k, err := dec.Token() + if err != nil { + return err + } + in, name = key, k.(string) + } + if err := walk(in, name); err != nil { + return err + } + } + _, err = dec.Token() // the closing delimiter + return err + } + return nil + } + // The payload has decoded, so the walk does not fail. + _ = walk("", "") + if out == nil { + return data + } + return append(out, data[copied:]...) +} + func offsetToLineCol(data []byte, offset int64) (int, int) { if offset < 0 { offset = 0 diff --git a/cmd/things/import_test.go b/cmd/things/import_test.go index b501a864..445e5489 100644 --- a/cmd/things/import_test.go +++ b/cmd/things/import_test.go @@ -8,7 +8,9 @@ import ( "path/filepath" "strings" "testing" + "time" + "github.com/ryanlewis/things-cli/internal/clock" "github.com/ryanlewis/things-cli/internal/things" ) @@ -158,3 +160,52 @@ func TestRunImportReadsDepsStdin(t *testing.T) { t.Errorf("data = %q, want %q", got, payload) } } + +// 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 +// 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) + for _, tc := range []struct{ name, in, want string }{ + {"todayAt", `[{"type":"to-do","attributes":{"title":"a","when":"today@6pm"}}]`, + `[{"type":"to-do","attributes":{"title":"a","when":"2026-10-10@18:00"}}]`}, + {"spacing kept", "[ {\"type\": \"to-do\",\n \"attributes\" : { \"when\" : \"tomorrow@9:30am\" , \"title\":\"a\"} } ]", + "[ {\"type\": \"to-do\",\n \"attributes\" : { \"when\" : \"2026-10-11@09:30\" , \"title\":\"a\"} } ]"}, + {"nested and update", `[{"type":"project","attributes":{"title":"P","when":"2026-10-12@7pm","items":[{"type":"to-do","attributes":{"title":"a","when":"today@18:00"}}]}},{"type":"to-do","operation":"update","id":"x","attributes":{"when":"tomorrow@8am"}}]`, + `[{"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"}}]`}, + {"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]}}]`, + `[{"type":"to-do","attributes":{"title":"é \"q\"","when":"2026-10-10@18:00","tags":[1,2.50]}}]`}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := string(resolveImportWhens([]byte(tc.in), now)); got != tc.want { + t.Errorf("got %s\nwant %s", got, tc.want) + } + }) + } +} + +// The payload sent carries the resolved when. +func TestImportSendsResolvedWhen(t *testing.T) { + database := seedFullDB(t) + captured := stubExec(t) + payload := `[{"type":"to-do","attributes":{"title":"Hi","when":"tomorrow@6pm"}}]` + path := filepath.Join(t.TempDir(), "payload.json") + if err := os.WriteFile(path, []byte(payload), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := runWith(t, database, "--no-verify", "import", "--file", path); err != nil { + t.Fatalf("runWith: %v", err) + } + parsed, err := url.Parse((*captured)[2]) + if err != nil { + t.Fatalf("parse url: %v", err) + } + want := `"when":"` + clock.Now().AddDate(0, 0, 1).Format("2006-01-02") + `@18:00"` + if got := parsed.Query().Get("data"); !strings.Contains(got, want) { + t.Errorf("data = %s, want %s", got, want) + } +} diff --git a/cmd/things/importcreate_test.go b/cmd/things/importcreate_test.go index 4a247999..7f32a5cc 100644 --- a/cmd/things/importcreate_test.go +++ b/cmd/things/importcreate_test.go @@ -1238,6 +1238,7 @@ func TestImportNestedInUpdateNotWarned(t *testing.T) { func TestImportChecksWhen(t *testing.T) { past := time.Now().AddDate(0, 0, -4).Format("2006-01-02") today := "start = 1, startDate = " + strconv.Itoa(int(model.ThingsDateFromTime(testNow))) + tomorrow := "start = 2, startDate = " + strconv.Itoa(int(model.ThingsDateFromTime(testNow.AddDate(0, 0, 1)))) for _, tc := range []struct { name string attrs string @@ -1252,6 +1253,11 @@ func TestImportChecksWhen(t *testing.T) { {name: "phraseIgnored", attrs: `"when":"blorp"`, reason: "when", landed: "in inbox with no start date", message: `Created, but Things did not understand its when: [0] "Pay rent" (new-1), in inbox with no start date`}, {name: "todayLanded", attrs: `"when":"today","deadline":"` + past + `"`, extra: today}, + // A date or keyword with a time is judged as sent, as a date and + // time (resolveImportWhens), so one that did not land is caught. + {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}, {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 5f898eb7..b84b523a 100644 --- a/cmd/things/importreadback.go +++ b/cmd/things/importreadback.go @@ -10,6 +10,7 @@ import ( "github.com/ryanlewis/things-cli/internal/db" "github.com/ryanlewis/things-cli/internal/model" "github.com/ryanlewis/things-cli/internal/output" + "github.com/ryanlewis/things-cli/internal/things" ) // importVerifyItem is one status change that never landed. wanted and got name @@ -192,16 +193,16 @@ type importCreated struct { detail string // why a failing item is missing or unconfirmed, for the plain-text error } -// applyImport sends write, the import, then reads it back: the items it -// created (readBackCreates) and the status changes it asked for -// (verifyImportStatuses). The whole import shares one timeout budget. Every -// created item is reported on stdout, confirmed or not; a created item that -// never appeared, or a status change that did not apply, makes the import -// fail with all of them named. Under --no-verify nothing is read back and -// every created item is reported unconfirmed. -func applyImport(d *Deps, database *db.DB, plan *importPlan, write func() error) error { +// applyImport sends write, the import, passing it the time it is sent, then +// reads it back: the items it created (readBackCreates) and the status +// changes it asked for (verifyImportStatuses). The whole import shares one +// timeout budget. Every created item is reported on stdout, confirmed or not; +// a created item that never appeared, or a status change that did not apply, +// makes the import fail with all of them named. Under --no-verify nothing is +// read back and every created item is reported unconfirmed. +func applyImport(d *Deps, database *db.DB, plan *importPlan, write func(sent time.Time) error) error { if d.NoVerify { - if err := write(); err != nil { + if err := write(clock.Now()); err != nil { return err } created := make([]importCreated, len(plan.creates)) @@ -223,7 +224,7 @@ func applyImport(d *Deps, database *db.DB, plan *importPlan, write func() error) snap, snapErr = snapshotCreated(database, types) } sent := clock.Now() - if err := write(); err != nil { + if err := write(sent); err != nil { return err } budget := d.readBackTimeout() @@ -534,7 +535,8 @@ func checkClosedAt(database *db.DB, out []importCreated, creates []importCreate, } // checkWhens turns back each confirmation of an item whose `when` did not -// file it, sent at sent, keeping its uuid, as applyAdd does for --when: a +// 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 // `when` it applies with a deadline already past in the Inbox, measured on @@ -552,7 +554,7 @@ func checkWhens(out []importCreated, creates []importCreate, found map[createdWa if row == nil { continue } - check := &whenCheck{value: c.when, sent: sent} + check := &whenCheck{value: c.when, sentAs: things.ResolveWhen(c.when, sent), sent: sent} switch check.verdict(row, now) { case whenNotFiled: out[i].Confirmed, out[i].Reason = false, whenMisfiled diff --git a/cmd/things/when.go b/cmd/things/when.go index 3209b52d..79c7b0ac 100644 --- a/cmd/things/when.go +++ b/cmd/things/when.go @@ -331,8 +331,7 @@ func unmovedKeeps(p whenPlace, now time.Time, task *model.Task) bool { type whenCheck struct { value string // sentAs is the value as sent (things.ResolveWhen), "" when it went as - // value, as an import's when does. It is what the read-back judges; - // messages name value, as typed. + // value. It is what the read-back judges; messages name value, as typed. sentAs string sent time.Time before *model.Task diff --git a/docs/content/commands.md b/docs/content/commands.md index 8df5f0c0..90f5a939 100644 --- a/docs/content/commands.md +++ b/docs/content/commands.md @@ -945,7 +945,9 @@ have `today`, `tomorrow`, `evening` 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 bare time and an RFC 3339 +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 @@ -1110,7 +1112,8 @@ an unconfirmed `add` does, and no `uuid` unless the reason is item is in `items` with its `id`; set the date in the Things app rather than importing the item again. A `completion-date` on an item the payload does not complete or cancel is ignored by Things and not checked. -- `misfiled`: the item's `when` is a keyword or a date, and Things filed +- `misfiled`: the item's `when` is a keyword, a date, or a date and time + (judged as sent, so `today@6pm` as today's date at `18:00`), and Things filed the new item somewhere else, as `add` reports a `--when` that did not land. A to-do sent with `when` `today` and a deadline already past is one case: Things files it in the Inbox with no start date. It carries its diff --git a/internal/skill/SKILL.md b/internal/skill/SKILL.md index e485e15e..104d7d82 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -149,7 +149,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. **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, 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; 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.** `--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. From 8a0fba58fb45dae20a7ad5ce800f276a765a6156 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sat, 10 Oct 2026 15:01:55 +0100 Subject: [PATCH 2/2] fix(verify): start the read-back window from clock.Now --- cmd/things/testmain_test.go | 8 ++++---- cmd/things/verify.go | 6 ++++-- cmd/things/verify_test.go | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/cmd/things/testmain_test.go b/cmd/things/testmain_test.go index 85afdbc7..f3f42cfb 100644 --- a/cmd/things/testmain_test.go +++ b/cmd/things/testmain_test.go @@ -18,10 +18,10 @@ var testNow = time.Now() // pinWallClock pins the clock to the wall time for the rest of t, for a test // whose import gives a creation-date of about now. The refusal of a date in -// the future reads the pinned clock, and the read-back window reads the wall -// clock, so the date has to fit both. testNow falls further behind the wall -// clock the longer the package runs, and a slow run (-race in CI) leaves it -// more than the minute's slack behind. +// the future and the read-back window read the pinned clock, and the test's +// date is the wall time. testNow falls further behind the wall clock the +// longer the package runs, and a slow run (-race in CI) leaves it more than +// the minute's slack behind. func pinWallClock(t *testing.T) { t.Helper() t.Cleanup(clock.Pin(time.Now())) diff --git a/cmd/things/verify.go b/cmd/things/verify.go index d60999b2..f8e2539a 100644 --- a/cmd/things/verify.go +++ b/cmd/things/verify.go @@ -483,9 +483,11 @@ type createdSnapshot struct { } // snapshotCreated records the items of each of types created in the last -// createdSlack, just before a write that creates items. +// createdSlack, just before a write that creates items. It reads clock.Now, +// as import's refusal of a future creation-date does, so the two agree on +// now. func snapshotCreated(database *db.DB, types []model.TaskType) (createdSnapshot, error) { - snap := createdSnapshot{since: time.Now().Add(-createdSlack), before: map[string]struct{}{}} + snap := createdSnapshot{since: clock.Now().Add(-createdSlack), before: map[string]struct{}{}} for _, typ := range types { existing, err := database.TasksCreatedSince(typ, snap.since) if err != nil { diff --git a/cmd/things/verify_test.go b/cmd/things/verify_test.go index 9581c12f..dc1e5e35 100644 --- a/cmd/things/verify_test.go +++ b/cmd/things/verify_test.go @@ -11,6 +11,7 @@ import ( "github.com/alecthomas/kong" + "github.com/ryanlewis/things-cli/internal/clock" "github.com/ryanlewis/things-cli/internal/db" "github.com/ryanlewis/things-cli/internal/db/dbtest" "github.com/ryanlewis/things-cli/internal/model" @@ -663,3 +664,18 @@ func TestVerifyStatusReportsTheItemThatDidNotChange(t *testing.T) { } } } + +// The read-back window starts from clock.Now, the clock import's refusal of +// a future creation-date reads, not the wall clock. +func TestSnapshotCreatedReadsClock(t *testing.T) { + database, _ := seedWritable(t) + pinned := time.Date(2026, 10, 10, 9, 0, 0, 0, time.Local) + t.Cleanup(clock.Pin(pinned)) + snap, err := snapshotCreated(database, []model.TaskType{model.TypeTask}) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if want := pinned.Add(-createdSlack); !snap.since.Equal(want) { + t.Errorf("since = %s, want %s", snap.since, want) + } +}