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
2 changes: 1 addition & 1 deletion internal/db/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
81 changes: 75 additions & 6 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package model

import (
"encoding/json"
"errors"
"fmt"
"time"
)
Expand All @@ -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

Expand Down Expand Up @@ -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"`
Expand All @@ -95,15 +164,15 @@ 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"`
}

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"`
Expand Down
80 changes: 80 additions & 0 deletions internal/model/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
4 changes: 2 additions & 2 deletions internal/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "[ ]"
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion internal/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}{
Expand Down
2 changes: 1 addition & 1 deletion internal/output/style.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions internal/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down