diff --git a/internal/db/projects.go b/internal/db/projects.go index 5a80275..034a332 100644 --- a/internal/db/projects.go +++ b/internal/db/projects.go @@ -52,7 +52,7 @@ func (d *DB) ListProjects(areaFilter string, includeCompleted bool) ([]model.Pro return nil, fmt.Errorf("scanning project: %w", err) } if status.Valid { - p.Status = int(status.Int64) + p.Status = model.Status(status.Int64) } if tagsStr != "" { p.Tags = strings.Split(tagsStr, "\x1f") diff --git a/internal/model/model.go b/internal/model/model.go index 577c510..4b42237 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -2,6 +2,7 @@ package model import ( "encoding/json" + "errors" "fmt" "time" ) @@ -10,15 +11,83 @@ const ( TypeTask = 0 TypeProject = 1 - StatusOpen = 0 - StatusCancelled = 2 - StatusCompleted = 3 + StatusOpen Status = 0 + StatusCancelled Status = 2 + StatusCompleted Status = 3 StartInbox = 0 StartAnytime = 1 StartSomeday = 2 ) +// Status is a Things3 task/project status. The underlying integers are the +// raw Things codes (0 = open, 2 = cancelled, 3 = completed — note there is no +// 1), but JSON renders the human-readable string so scripts and agents never +// have to decode the magic ints. +type Status int + +// statusNames is the single source of truth for the name<->code mapping used +// by String, MarshalJSON, and UnmarshalJSON. +var statusNames = map[Status]string{ + StatusOpen: "open", + StatusCancelled: "cancelled", + StatusCompleted: "completed", +} + +func (s Status) String() string { + if name, ok := statusNames[s]; ok { + return name + } + return "unknown" +} + +// MarshalJSON renders a recognized status as its string name +// ("open"/"cancelled"/"completed"). An unrecognized raw Things code is +// preserved as its integer so the value round-trips losslessly rather than +// collapsing to a lossy "unknown" string. +func (s Status) MarshalJSON() ([]byte, error) { + if name, ok := statusNames[s]; ok { + return json.Marshal(name) + } + return json.Marshal(int(s)) +} + +// UnmarshalJSON accepts either a status name or the raw Things integer, +// mirroring MarshalJSON so values round-trip. Names are matched strictly +// against the known set; integers are taken verbatim as the raw wire code. +func (s *Status) UnmarshalJSON(data []byte) error { + // Per the json.Unmarshaler convention, a JSON null is a no-op: leave the + // existing value untouched rather than silently coercing it to Status(0) + // ("open"). + if string(data) == "null" { + return nil + } + // Try the string name first; on a type mismatch fall back to the raw + // integer so both the emitted string form and the legacy integer decode. A + // non-type error (malformed JSON) is surfaced as-is rather than retried as + // an int. + var name string + if err := json.Unmarshal(data, &name); err != nil { + var typeErr *json.UnmarshalTypeError + if !errors.As(err, &typeErr) { + return fmt.Errorf("Status: %w", err) + } + var n int + if err := json.Unmarshal(data, &n); err != nil { + return fmt.Errorf("Status: %w", err) + } + *s = Status(n) + return nil + } + for st, n := range statusNames { + if n == name { + *s = st + return nil + } + } + return fmt.Errorf("Status: unknown value %q", name) +} + // ThingsDate is a bit-encoded date: year<<16 | month<<12 | day<<7. type ThingsDate int64 @@ -73,7 +142,7 @@ type Task struct { Title string `json:"title"` Notes string `json:"notes,omitempty"` Type int `json:"type"` - Status int `json:"status"` + Status Status `json:"status"` Start int `json:"start"` StartBucket int `json:"startBucket"` StartDate *ThingsDate `json:"startDate,omitempty"` @@ -95,7 +164,7 @@ type Task struct { type ChecklistItem struct { UUID string `json:"uuid"` Title string `json:"title"` - Status int `json:"status"` + Status Status `json:"status"` StopDate *time.Time `json:"stopDate,omitempty"` Index int `json:"index"` } @@ -103,7 +172,7 @@ type ChecklistItem struct { type Project struct { UUID string `json:"uuid"` Title string `json:"title"` - Status int `json:"status"` + Status Status `json:"status"` AreaUUID string `json:"areaUUID,omitempty"` AreaTitle string `json:"areaTitle,omitempty"` Tags []string `json:"tags,omitempty"` diff --git a/internal/model/model_test.go b/internal/model/model_test.go index 899751c..130d233 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -135,3 +135,83 @@ func TestCoreDataEpochZero(t *testing.T) { t.Fatalf("CoreDataToTime(0) = %s, want %s", got, epoch) } } + +func TestStatusMarshalJSON(t *testing.T) { + cases := []struct { + status Status + want string + }{ + {StatusOpen, `"open"`}, + {StatusCancelled, `"cancelled"`}, + {StatusCompleted, `"completed"`}, + {Status(99), `99`}, // unrecognized code preserved as its raw int + } + for _, tc := range cases { + got, err := json.Marshal(tc.status) + if err != nil { + t.Fatalf("Marshal(%d): %v", tc.status, err) + } + if string(got) != tc.want { + t.Errorf("Marshal(%d) = %s, want %s", tc.status, got, tc.want) + } + } +} + +func TestStatusUnmarshalJSON(t *testing.T) { + cases := []struct { + in string + want Status + }{ + {`"open"`, StatusOpen}, + {`"cancelled"`, StatusCancelled}, + {`"completed"`, StatusCompleted}, + {`0`, StatusOpen}, // legacy integer input + {`2`, StatusCancelled}, // legacy integer input (the non-obvious code) + {`3`, StatusCompleted}, // legacy integer input + {`99`, Status(99)}, // unrecognized raw code taken verbatim + } + for _, tc := range cases { + var s Status + if err := json.Unmarshal([]byte(tc.in), &s); err != nil { + t.Fatalf("Unmarshal(%s): %v", tc.in, err) + } + if s != tc.want { + t.Errorf("Unmarshal(%s) = %d, want %d", tc.in, s, tc.want) + } + } + // A JSON null is a no-op: it must leave the existing value untouched rather + // than silently coercing it to Status(0) ("open"). + pre := StatusCompleted + if err := json.Unmarshal([]byte(`null`), &pre); err != nil { + t.Fatalf("Unmarshal(null): %v", err) + } + if pre != StatusCompleted { + t.Errorf("Unmarshal(null) = %d, want %d (unchanged)", pre, StatusCompleted) + } + // Unknown string names are rejected, but a malformed JSON token must not be + // silently funnelled into the integer branch. + for _, bad := range []string{`"bogus"`, `{}`, `[1]`} { + var s Status + if err := json.Unmarshal([]byte(bad), &s); err == nil { + t.Errorf("Unmarshal(%s) succeeded, want error", bad) + } + } +} + +func TestStatusRoundTripJSON(t *testing.T) { + // Both a recognized status and an unrecognized raw code must round-trip. + for _, want := range []Status{StatusCancelled, StatusCompleted, Status(99)} { + in := Task{Title: "t", Status: want} + data, err := json.Marshal(in) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var out Task + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if out.Status != want { + t.Errorf("round-trip status = %d, want %d", out.Status, want) + } + } +} diff --git a/internal/output/output.go b/internal/output/output.go index e86da40..e6f1732 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -296,7 +296,7 @@ func printTags(w io.Writer, tags []model.Tag) error { return nil } -func statusIcon(status int) string { +func statusIcon(status model.Status) string { switch status { case model.StatusOpen: return "[ ]" @@ -309,7 +309,7 @@ func statusIcon(status int) string { } } -func statusText(status int) string { +func statusText(status model.Status) string { switch status { case model.StatusOpen: return "Open" diff --git a/internal/output/output_test.go b/internal/output/output_test.go index c7ac82f..422a6de 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -255,7 +255,7 @@ func TestPrintFallbackJSON(t *testing.T) { func TestStatusHelpers(t *testing.T) { cases := []struct { - status int + status model.Status icon string text string }{ diff --git a/internal/output/style.go b/internal/output/style.go index ad64ca0..9983224 100644 --- a/internal/output/style.go +++ b/internal/output/style.go @@ -77,7 +77,7 @@ var ( // nowFn is overridable in tests. var nowFn = time.Now -func styledStatus(status int) string { +func styledStatus(status model.Status) string { icon := statusIcon(status) switch status { case model.StatusCompleted: diff --git a/internal/skill/SKILL.md b/internal/skill/SKILL.md index 2eaed88..ae8ff24 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -13,6 +13,8 @@ today, upcoming, projects, or areas on macOS. Most commands accept `--json` / `-j`. Prefer it when parsing output. +In JSON, `status` is a string enum — `"open"`, `"cancelled"`, or `"completed"` (not the raw Things integer) — on tasks, projects, and checklist items. Filter with e.g. `jq 'select(.status=="open")'`. + Human output is styled with colors and aligned columns. Color auto-disables when piping or when `NO_COLOR` is set. Override with `--color=always|never` (default `auto`). JSON output is unaffected. ## Core commands