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
66 changes: 64 additions & 2 deletions cmd/things/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"time"

"github.com/ryanlewis/things-cli/internal/things"
)
Expand Down Expand Up @@ -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)
})
}

Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions cmd/things/import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/ryanlewis/things-cli/internal/clock"
"github.com/ryanlewis/things-cli/internal/things"
)

Expand Down Expand Up @@ -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)
}
}
6 changes: 6 additions & 0 deletions cmd/things/importcreate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"},
} {
Expand Down
26 changes: 14 additions & 12 deletions cmd/things/importreadback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions cmd/things/testmain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
6 changes: 4 additions & 2 deletions cmd/things/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions cmd/things/verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}
3 changes: 1 addition & 2 deletions cmd/things/when.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions docs/content/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading