taskai-cli 1.9.26__tar.gz → 1.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/CHANGELOG.md +24 -0
  2. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/DEVPLAN.md +1 -1
  3. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/PKG-INFO +6 -4
  4. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/README.md +5 -3
  5. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/_static/demo-script.md +2 -2
  6. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/browser-mode.md +1 -1
  7. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/configuration.md +4 -2
  8. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/getting-started.md +1 -1
  9. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/index.md +1 -1
  10. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/item-data.md +27 -4
  11. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/managing-the-tree.md +3 -3
  12. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/pyproject.toml +1 -1
  13. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/cli.py +33 -10
  14. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/dates.py +2 -2
  15. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/filters.py +2 -2
  16. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/help_menu.py +13 -5
  17. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/json_dir_database.py +25 -0
  18. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/models.py +2 -2
  19. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/static/editpanel.js +4 -4
  20. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/static/graph.js +1 -1
  21. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/static/navigation.js +9 -7
  22. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/static/render.js +2 -2
  23. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/views.py +51 -8
  24. taskai_cli-1.10.0/test/test_legacy_migration.py +73 -0
  25. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/test/test_view.py +1 -1
  26. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/.github/workflows/publish-to-pypi.yml +0 -0
  27. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/.github/workflows/test.yml +0 -0
  28. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/.gitignore +0 -0
  29. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/.readthedocs.yaml +0 -0
  30. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/DEVLOG.md +0 -0
  31. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/PHASE_1_RELEASE.md +0 -0
  32. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/TRIAGE.md +0 -0
  33. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/.gitignore +0 -0
  34. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/_static/.gitkeep +0 -0
  35. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/ai-integration.md +0 -0
  36. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/commands.md +0 -0
  37. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/conf.py +0 -0
  38. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/interactive-mode.md +0 -0
  39. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/docs/requirements.txt +0 -0
  40. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/scripts/bump_version.py +0 -0
  41. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/browser.py +0 -0
  42. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/config.py +0 -0
  43. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/errors.py +0 -0
  44. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/llm_models.py +0 -0
  45. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/services/ai.py +0 -0
  46. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/services/pomodoro.py +0 -0
  47. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/services/repair_database.py +0 -0
  48. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/services/user_setup.py +0 -0
  49. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/static/camera.js +0 -0
  50. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/static/canvas.js +0 -0
  51. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/static/config.js +0 -0
  52. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/static/console.js +0 -0
  53. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/static/index.html +0 -0
  54. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/static/shortcuts.js +0 -0
  55. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/taskai/static/style.css +0 -0
  56. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/test/test_chains.py +0 -0
  57. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/test/test_cli.py +0 -0
  58. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/test/test_execution.py +0 -0
  59. {taskai_cli-1.9.26 → taskai_cli-1.10.0}/test/test_json_dir_database.py +0 -0
@@ -6,6 +6,30 @@ history. Versions before 1.6.6 predate this file and aren't reconstructed
6
6
  here in detail — see `DEVLOG.md` for the full engineering history back to
7
7
  the project's start.
8
8
 
9
+ ## [1.10.0] - Unreleased
10
+
11
+ CLI expansion.
12
+
13
+ ### Added
14
+ - A dedicated command for every item field: `task due 10 tomorrow`,
15
+ `task description 10 ...`, `task priority`, `task color` (`task status`
16
+ already existed). The value needn't be quoted, and leaving it off clears
17
+ the field. `task name` is now an alias for `task rename`.
18
+ - `task description {id|name} -a {text}` appends to the existing description
19
+ (on a new line) instead of replacing it
20
+
21
+ - `task show` draws an item in its `color` (the same per-item color the
22
+ browser view uses), overriding the `DISPLAY_COLORS` columns for that item
23
+
24
+ ### Changed
25
+ - The CLI shows dates as just the day, `MM/DD/YY` (due dates, `created_on`,
26
+ comment timestamps) - the time of day is no longer displayed
27
+ - `due_by` is now just `due` everywhere: `--due`, `task show 'due<...'`, the
28
+ `DISPLAY_STRING` attribute, and the item JSON. Existing databases upgrade
29
+ themselves on load - a stored `due_by` is read in as `due` (only when `due`
30
+ isn't already set) and written back out under the new name, and a saved
31
+ `DISPLAY_STRING` that names `due_by` is rewritten too.
32
+
9
33
  ## [Unreleased]
10
34
 
11
35
  The v1 release polish pass — see `PHASE_1_RELEASE.md` for the full working
@@ -19,7 +19,7 @@ generic tree node: `parent_id` + `child_ids` form the hierarchy (a root item
19
19
  has `parent_id = None`); any item can be a parent of any other item to any
20
20
  depth, and the same node type is used whether it's acting as a "list" or a
21
21
  "task." Other `TodoItem` fields: `dependency_ids` (blocks-on), `linked_ids`
22
- (soft link, not reparenting), `comment_ids`, `due_by`, `priority`, `status`,
22
+ (soft link, not reparenting), `comment_ids`, `due`, `priority`, `status`,
23
23
  `completed`, `description`, and unused-so-far recurrence fields
24
24
  (`recurs_every`/`recurs_until`/`recur_keep_incomplete`). `Comment` is a
25
25
  simple `{content, item_id, created_on}`. `UserData` is the whole-DB
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskai-cli
3
- Version: 1.9.26
3
+ Version: 1.10.0
4
4
  Author-email: Alex Paskal <alexcpaskal@gmail.com>
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: fastapi>=0.141.1
@@ -55,7 +55,8 @@ task link <parent> <item> soft-link an item under a second parent
55
55
  task unlink <parent> <item> remove a soft-link
56
56
  task reorder <id1> before|after <id2>
57
57
 
58
- task status <id> <text> set a status string
58
+ task <field> <id> <value> set one field: description, due, priority, status, color
59
+ (leave the value off to clear it)
59
60
  task pomo <on_mins> <off_mins> pomodoro timer
60
61
 
61
62
  task ai <prompt> natural language → commands
@@ -81,10 +82,11 @@ Pass any of these as `--field value` to `create`, `add`, or `update`:
81
82
  | Field | Format | Example |
82
83
  |---|---|---|
83
84
  | `description` | string | `--description "needs review"` |
84
- | `due_by` | MM-DD-YYYY | `--due_by 08-15-2026` |
85
+ | `due` | MM-DD-YYYY | `--due 08-15-2026` |
85
86
  | `priority` | integer | `--priority 2` |
86
87
  | `status` | string | `--status "in progress"` |
87
88
  | `completed` | bool | `--completed true` |
89
+ | `color` | CSS color | `--color "#ff8844"` |
88
90
 
89
91
  ---
90
92
 
@@ -154,7 +156,7 @@ task create "Launch website"
154
156
  # add subtasks (by name or id)
155
157
  task add "Launch website" "Write copy"
156
158
  task add "Launch website" "Design mockups" --priority 1
157
- task add "Launch website" "Set up hosting" --due_by 08-15-2026
159
+ task add "Launch website" "Set up hosting" --due 08-15-2026
158
160
 
159
161
  # check your work
160
162
  task show "Launch website"
@@ -42,7 +42,8 @@ task link <parent> <item> soft-link an item under a second parent
42
42
  task unlink <parent> <item> remove a soft-link
43
43
  task reorder <id1> before|after <id2>
44
44
 
45
- task status <id> <text> set a status string
45
+ task <field> <id> <value> set one field: description, due, priority, status, color
46
+ (leave the value off to clear it)
46
47
  task pomo <on_mins> <off_mins> pomodoro timer
47
48
 
48
49
  task ai <prompt> natural language → commands
@@ -68,10 +69,11 @@ Pass any of these as `--field value` to `create`, `add`, or `update`:
68
69
  | Field | Format | Example |
69
70
  |---|---|---|
70
71
  | `description` | string | `--description "needs review"` |
71
- | `due_by` | MM-DD-YYYY | `--due_by 08-15-2026` |
72
+ | `due` | MM-DD-YYYY | `--due 08-15-2026` |
72
73
  | `priority` | integer | `--priority 2` |
73
74
  | `status` | string | `--status "in progress"` |
74
75
  | `completed` | bool | `--completed true` |
76
+ | `color` | CSS color | `--color "#ff8844"` |
75
77
 
76
78
  ---
77
79
 
@@ -141,7 +143,7 @@ task create "Launch website"
141
143
  # add subtasks (by name or id)
142
144
  task add "Launch website" "Write copy"
143
145
  task add "Launch website" "Design mockups" --priority 1
144
- task add "Launch website" "Set up hosting" --due_by 08-15-2026
146
+ task add "Launch website" "Set up hosting" --due 08-15-2026
145
147
 
146
148
  # check your work
147
149
  task show "Launch website"
@@ -15,7 +15,7 @@ and the tree is uncluttered.
15
15
 
16
16
  ```bash
17
17
  task create "Launch blog"
18
- task add "Launch blog" "Write outline" --priority 1 --due_by tomorrow
18
+ task add "Launch blog" "Write outline" --priority 1 --due tomorrow
19
19
  task next "Write outline" "Draft intro"
20
20
  task show all
21
21
  task done "Write outline"
@@ -25,7 +25,7 @@ task show all
25
25
 
26
26
  What this covers, in order:
27
27
  1. `create`/`add` — the everything-is-an-item tree, with a couple of fields
28
- set (`--priority`, `--due_by tomorrow` — also shows off the relative
28
+ set (`--priority`, `--due tomorrow` — also shows off the relative
29
29
  date keyword)
30
30
  2. `next` — chains, the newest major feature, right after the tree basics
31
31
  3. `show all` — the Rich-rendered tree, chain included (rendered inline
@@ -19,7 +19,7 @@ Double-click a card to center on it and open its edit panel.
19
19
  - **Completed** items are tinted green.
20
20
  - **`status`** text appears in the top corner of a card (or the color set by
21
21
  `STATUS_COLORS` in config, if one's configured for that status).
22
- - **`due_by`**, if set, appears in the bottom corner.
22
+ - **`due`**, if set, appears in the bottom corner.
23
23
  - **Soft links** (`task link`) are drawn as a dashed copy of the linked item
24
24
  under its host, distinct from the solid parent/child connectors.
25
25
  - **Chains** (`task next`/`task chain`) render as a straight bold arrow; a
@@ -20,13 +20,15 @@ task config pop <key> # remove a key; it returns to its default
20
20
 
21
21
  `DISPLAY_STRING`
22
22
  : Space-separated list of item attributes shown per row by `task show`.
23
- Default: `id name status due_by`.
23
+ Default: `id name status due`.
24
24
 
25
25
  `DISPLAY_COLORS`
26
26
  : Space-separated colors, matched position-for-position to `DISPLAY_STRING`.
27
27
  Use `_` to leave a column uncolored. Default: `_ white dark_orange _`.
28
+ An item with its own [`color`](item-data.md) set is drawn entirely in that
29
+ color instead, whatever these say.
28
30
 
29
31
  ```bash
30
- task config set DISPLAY_STRING "id name priority due_by"
32
+ task config set DISPLAY_STRING "id name priority due"
31
33
  task config set DISPLAY_COLORS "_ white cyan _"
32
34
  ```
@@ -59,7 +59,7 @@ Every item is a tree node. Start with a root item, then hang children off it.
59
59
  Names with spaces must be quoted.
60
60
 
61
61
  ```bash
62
- task create "Launch blog" --due_by 09-15-2026
62
+ task create "Launch blog" --due 09-15-2026
63
63
  task add "Launch blog" "Write first post" --priority 1
64
64
  task add "Launch blog" "Set up hosting"
65
65
  task add "Write first post" "Draft outline"
@@ -15,7 +15,7 @@ A command-line task manager with AI features.
15
15
  items at any depth; there's no separate notion of "lists"
16
16
  - **Attach data to each item** — description, due date, priority, status,
17
17
  completion, comments
18
- - **Filter** — `task show 'priority>=2' 'due_by<10-01-2026'` narrows the tree to
18
+ - **Filter** — `task show 'priority>=2' 'due<10-01-2026'` narrows the tree to
19
19
  matching items and their parents
20
20
  - **Soft-link** an item under more than one parent without moving it
21
21
  - **Interactive mode** — run `task` with no arguments for a live view that
@@ -5,25 +5,48 @@ fields. Set them as named options — `--<name> <value>` — on `task create`,
5
5
  `task add`, and `task update`:
6
6
 
7
7
  ```bash
8
- task create "Ship v2" --due_by 10-01-2026 --priority 2
8
+ task create "Ship v2" --due 10-01-2026 --priority 2
9
9
  task update "Ship v2" --status "in progress" --description "cut the release branch first"
10
10
  ```
11
11
 
12
12
  | Option | Value | Meaning |
13
13
  |---|---|---|
14
14
  | `--description` | text | A longer note on the item. Quote it if it has spaces. |
15
- | `--due_by` | `MM-DD-YYYY` | When the item is due. |
15
+ | `--due` | `MM-DD-YYYY` | When the item is due. |
16
16
  | `--priority` | integer | A number you choose; sort or filter by it. Default `0`. |
17
17
  | `--status` | text | A free-form state such as `blocked` or `in review`. Shown on the node in the browser view. |
18
18
  | `--completed` | `true` / `false` | Whether the item is done. |
19
+ | `--color` | a CSS color, e.g. `"#ff8844"` or `green` | Fills the item's card in the browser view, and draws its whole line in `task show` in that color (overriding the `DISPLAY_COLORS` columns). |
19
20
 
20
21
  ## Dedicated commands
21
22
 
22
- Some changes have their own command instead of an option:
23
+ Every field above also has its own command, so you can skip `update` and the
24
+ `--` for the common case:
25
+
26
+ ```bash
27
+ task due 4 tomorrow # same as: task update 4 --due tomorrow
28
+ task priority 4 2
29
+ task status 4 "in review"
30
+ task color 4 "#ff8844"
31
+ task description 4 needs a rewrite # the value needn't be quoted
32
+ task due 4 # no value clears the field
33
+ ```
34
+
35
+ The value is everything after the item, so multi-word text and dates like
36
+ `task due 4 Dec 31 2026` work without quotes.
37
+
38
+ `task description` takes an `-a` flag to append to the existing description
39
+ (on a new line) instead of replacing it:
40
+
41
+ ```bash
42
+ task description 4 first draft outline
43
+ task description 4 -a also cover the hosting options
44
+ ```
45
+
46
+ A few other changes have their own command too:
23
47
 
24
48
  ```bash
25
49
  task rename 4 "Ship v2.1"
26
- task status 4 "in review" # same as --status, as its own verb
27
50
  task comment 4 "waiting on QA sign-off"
28
51
  task complete 4 # mark item 4 done
29
52
  task done 4 # alias for complete
@@ -21,7 +21,7 @@ already exists — give the parent first, then the new item's name. Both accept
21
21
  [item data](item-data.md) as named options:
22
22
 
23
23
  ```bash
24
- task add "Launch blog" "Set up hosting" --due_by 09-30-2026 --priority 1
24
+ task add "Launch blog" "Set up hosting" --due 09-30-2026 --priority 1
25
25
  ```
26
26
 
27
27
  ## View items
@@ -40,11 +40,11 @@ the items matching *all* of them, each with its parent chain for context:
40
40
  ```bash
41
41
  task show 'name=Write*' # = on text is a glob match
42
42
  task show 'priority>=2' 'completed=false'
43
- task show 'status=in review' 'due_by<10-01-2026'
43
+ task show 'status=in review' 'due<10-01-2026'
44
44
  ```
45
45
 
46
46
  Operators are `=`, `>`, `<`, `>=`, `<=`. Text fields (`name`, `description`,
47
- `status`) glob-match on `=`; `priority`, `due_by`, `created_on`, and `id`
47
+ `status`) glob-match on `=`; `priority`, `due`, `created_on`, and `id`
48
48
  compare numerically / by date. Put no space around the operator, and quote each
49
49
  test so your shell doesn't treat `>` and `<` as redirects.
50
50
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskai-cli"
3
- version = "1.9.26"
3
+ version = "1.10.0"
4
4
  description = ""
5
5
  authors = [{name = "Alex Paskal", email = "alexcpaskal@gmail.com"}]
6
6
  readme = "README.md"
@@ -113,9 +113,9 @@ class Controller:
113
113
  kwargs["completed"] = v
114
114
  else:
115
115
  kwargs["completed"] = str(v).strip().lower() in ("true", "1", "yes")
116
- case "due_by":
116
+ case "due":
117
117
  if isinstance(v, str):
118
- kwargs["due_by"] = parse_date_value(v)
118
+ kwargs["due"] = parse_date_value(v)
119
119
  case "depends_on":
120
120
  # CLI-facing alias for the model's dependency_ids field;
121
121
  # accepts a comma-separated id list ("1,2,3") or one id
@@ -207,6 +207,25 @@ class Controller:
207
207
 
208
208
  db.commit()
209
209
 
210
+ def set_attribute(item_id: int|str, attr: str, value: str = "", append: bool = False):
211
+ """Backs the dedicated `task <attr> {id|name} {value}` commands: set
212
+ one item field. An empty value clears it back to the field's default
213
+ (no due date, priority 0, no color, ...). With `append` (description
214
+ only) the value goes on a new line after the existing text instead of
215
+ replacing it."""
216
+ if append:
217
+ if attr != "description":
218
+ Controller.throw_error(f"-a only applies to description, not {attr}")
219
+ if value == "":
220
+ Controller.throw_error("nothing to append - give the text to add")
221
+ item = Controller._resolve_item(item_id)
222
+ item_id = item.id
223
+ if item.description.strip():
224
+ value = f"{item.description.rstrip()}\n{value}"
225
+ elif value == "":
226
+ value = TodoItem.model_fields[attr].default
227
+ Controller.update_item(item_id, **{attr: value})
228
+
210
229
  def delete_item(id_: int|str, chain: bool = False):
211
230
  item = Controller._resolve_item(id_)
212
231
 
@@ -596,9 +615,20 @@ def execute_commands(*args, **kwargs) -> int:
596
615
  case "move":
597
616
  Controller.move_item(args[1], args[2])
598
617
 
599
- case "rename":
618
+ case "rename" | "name":
600
619
  Controller.update_item(args[1], name=args[2])
601
620
 
621
+ # one dedicated command per item field: `task due 10 tomorrow`.
622
+ # The value is everything after the item, so it needn't be quoted
623
+ # (`task description 10 needs a rewrite`); leaving it off clears
624
+ # it, and `description -a` appends to the existing text instead
625
+ case "description" | "due" | "priority" | "color" | "status":
626
+ append = "-a" in args
627
+ words = [a for a in args[1:] if a != "-a"]
628
+ if not words:
629
+ Controller.throw_error(f"usage: task {args[0]} {{id|name}} {{value}}")
630
+ Controller.set_attribute(words[0], args[0], " ".join(words[1:]), append=append)
631
+
602
632
  case "pomo":
603
633
  pomodoro_service(int(args[1]), int(args[2]))
604
634
 
@@ -607,13 +637,6 @@ def execute_commands(*args, **kwargs) -> int:
607
637
  import orjson as json
608
638
  print_json(json.dumps(db.get_item(int(args[1])).model_dump()).decode())
609
639
 
610
- case "status":
611
- if len(args) < 3:
612
- item_id, status_val = args[1], ""
613
- else:
614
- item_id, status_val = args[1:3]
615
- Controller.update_item(item_id, status=status_val)
616
-
617
640
  case "exit" | "exit()" | "quit":
618
641
  return 0
619
642
 
@@ -1,7 +1,7 @@
1
1
  """Shared due-date parsing: a couple of relative keywords plus a handful of
2
- common formats, tried in turn. Used by both the CLI's --due_by/create/
2
+ common formats, tried in turn. Used by both the CLI's --due/create/
3
3
  update path (cli.py's _parse_item_kwargs) and date-attribute filter values
4
- (filters.py's due_by/created_on) so the two don't drift apart.
4
+ (filters.py's due/created_on) so the two don't drift apart.
5
5
  """
6
6
 
7
7
  from datetime import datetime, timedelta
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Attribute filters for `task show`.
3
3
 
4
- task show status="IN PROGRESS" priority>=2 due_by<12-31-2026
4
+ task show status="IN PROGRESS" priority>=2 due<12-31-2026
5
5
 
6
6
  Each argument is one filter, `attr<op>value`, with no spaces around the
7
7
  operator (a quoted value with spaces still arrives as a single token). Every
@@ -23,7 +23,7 @@ from taskai.json_dir_database import DatabaseError
23
23
 
24
24
  # attr -> how both sides of the comparison should be read
25
25
  _INT_ATTRS = {"id", "parent_id", "priority"}
26
- _DATE_ATTRS = {"due_by", "created_on"}
26
+ _DATE_ATTRS = {"due", "created_on"}
27
27
  _BOOL_ATTRS = {"completed"}
28
28
  _STR_ATTRS = {"name", "description", "status"}
29
29
 
@@ -15,8 +15,8 @@ Viewing:
15
15
  'task show {attr}{op}{value} ...' --> show only items matching every filter, each with its parent chain for context
16
16
  ops: = > < >= <= ; text uses = with fnmatch (name=Write*), the rest compare numbers/dates
17
17
  no spaces around the operator; quote each expression so your shell doesn't treat > < as redirects
18
- e.g. task show 'status=IN PROGRESS' 'priority>=2' 'due_by<12-31-2026'
19
- due_by/created_on also accept the keywords today/tomorrow, e.g. 'due_by=today', 'due_by<tomorrow'
18
+ e.g. task show 'status=IN PROGRESS' 'priority>=2' 'due<12-31-2026'
19
+ due/created_on also accept the keywords today/tomorrow, e.g. 'due=today', 'due<tomorrow'
20
20
 
21
21
  Creating:
22
22
  'task create {name} {--field value ...}' --> create a new top-level (root) item - use this when there's no existing parent to attach to
@@ -25,7 +25,10 @@ Creating:
25
25
  Modifying items (an id or a name works everywhere):
26
26
  'task update {id|name} {--field value ...}' --> update fields on an item
27
27
  'task rename {id|name} {new name}' --> rename an item
28
- 'task status {id|name} {text}' --> set an item's status string
28
+ 'task {field} {id|name} {value}' --> set one item field with its own command; field is one of description, due, priority, status, color
29
+ e.g. task due 10 tomorrow / task priority 10 2 / task status 10 "in review" / task color 10 "#ff8844"
30
+ the value needn't be quoted (task description 10 needs a rewrite); leave it off to clear the field (task due 10)
31
+ task description {id|name} -a {text} --> append text to the existing description, on a new line, instead of replacing it
29
32
  'task comment {id|name} {text}' --> add a comment to an item
30
33
  'task complete {id|name}' / 'task done {id|name}' --> mark an item complete; add -r (or -recursive) to also complete all its descendants
31
34
  'task undone {id|name}' --> mark an item not complete; add -r (or -recursive) to also un-complete all its descendants
@@ -50,10 +53,11 @@ Deleting:
50
53
 
51
54
  Item fields (pass as named options, e.g. --priority 2, to create/add/update):
52
55
  description string
53
- due_by MM-DD-YYYY, YYYY-MM-DD, MM/DD/YYYY, "Dec 31, 2026", or the keywords today/tomorrow
56
+ due MM-DD-YYYY, YYYY-MM-DD, MM/DD/YYYY, "Dec 31, 2026", or the keywords today/tomorrow
54
57
  priority integer
55
58
  status string
56
59
  completed true|false
60
+ color a CSS color such as #ff8844 or green - fills the item's card in the browser and colors its whole line in `task show`
57
61
 
58
62
  AI:
59
63
  'task ai {prompt}' --> feed a prompt to an LLM, which converts it into a series of the commands above and runs them
@@ -84,7 +88,7 @@ help_examples = """
84
88
  Worked examples - copy/paste and adapt. Names with spaces must be quoted.
85
89
 
86
90
  Build a small project tree:
87
- task create "Launch blog" --due_by 09-15-2026
91
+ task create "Launch blog" --due 09-15-2026
88
92
  task add "Launch blog" "Write first post" --priority 1
89
93
  task add "Launch blog" "Set up hosting"
90
94
  task add "Write first post" "Draft outline"
@@ -92,6 +96,10 @@ Build a small project tree:
92
96
 
93
97
  Work an item and finish it:
94
98
  task status 4 "in progress"
99
+ task due 4 tomorrow # every item field has a command like this
100
+ task priority 4 2
101
+ task description 4 first draft outline
102
+ task description 4 -a "also cover the hosting options" # -a appends instead of replacing
95
103
  task comment 4 "stuck on the intro paragraph"
96
104
  task done 4 # completes just item 4
97
105
  task done 4 -r # completes item 4 and everything under it
@@ -23,6 +23,30 @@ def _is_chain_head(item: TodoItem) -> bool:
23
23
  return item.prev_chain_id is None and item.next_chain_id is not None
24
24
 
25
25
 
26
+ def _migrate_legacy_keys(user_data: UserData) -> None:
27
+ """Backwards compatibility for data written before `due_by` became `due`.
28
+
29
+ Runs once per load, right after the file is read, so everything
30
+ downstream - raw-dict reads like get_item_attr included - only ever sees
31
+ the current names; the next commit writes them back out under the new
32
+ ones. A legacy `due_by` only fills in `due` when it has a value and `due`
33
+ doesn't, and the legacy key is dropped either way so it can't linger.
34
+ """
35
+ for item in user_data.todo_items.values():
36
+ if "due_by" in item:
37
+ legacy_due = item.pop("due_by")
38
+ if legacy_due and not item.get("due"):
39
+ item["due"] = legacy_due
40
+
41
+ # a saved DISPLAY_STRING can name the old attribute too (the default
42
+ # used to, and `task config set` persists every default it fills in)
43
+ display_string = user_data.config.get("DISPLAY_STRING")
44
+ if display_string:
45
+ user_data.config["DISPLAY_STRING"] = " ".join(
46
+ "due" if attr == "due_by" else attr for attr in display_string.split(" ")
47
+ )
48
+
49
+
26
50
  class JsonDirectoryDatabase:
27
51
  """
28
52
  All views extracted from the database are read only
@@ -59,6 +83,7 @@ class JsonDirectoryDatabase:
59
83
  )
60
84
  else:
61
85
  self.user_data = UserData(**json.loads(json_bstring))
86
+ _migrate_legacy_keys(self.user_data)
62
87
  else:
63
88
  raise DatabaseError("Database already connected")
64
89
 
@@ -36,7 +36,7 @@ class TodoItem(Base):
36
36
  created_on: datetime = Field(default_factory=datetime.now)
37
37
  completed: bool = False
38
38
  description: str = ""
39
- due_by: Optional[datetime] = None
39
+ due: Optional[datetime] = None
40
40
  comment_ids: list[int] = Field(default_factory=list)
41
41
  dependency_ids: list[int] = Field(default_factory=list)
42
42
  linked_ids: list[int] = Field(default_factory=list)
@@ -73,7 +73,7 @@ class CLIConfig(Base):
73
73
  AI_MODEL: Optional[str] = None
74
74
 
75
75
  # VIEW
76
- DISPLAY_STRING: str = "id name status due_by"
76
+ DISPLAY_STRING: str = "id name status due"
77
77
  DISPLAY_COLORS: str = "_ white dark_orange _"
78
78
  # per-status-string color for the DAG node's status label, e.g.
79
79
  # "RUNNING=green,BLOCKED=red" - browser-only today (CLI status display
@@ -25,7 +25,7 @@ function isoToDateInputValue(iso) {
25
25
  }
26
26
 
27
27
  // "YYYY-MM-DD" (native <input type="date"> value) -> "MM-DD-YYYY", matching
28
- // the CLI's documented --due_by format
28
+ // the CLI's documented --due format
29
29
  function dateInputValueToCliFormat(value) {
30
30
  const [year, month, day] = value.split("-");
31
31
  return `${month}-${day}-${year}`;
@@ -42,7 +42,7 @@ const FIELD_DEFS = [
42
42
  // but an empty string here correctly means "no override, use the default"
43
43
  { key: "color", label: "Color", type: "text" },
44
44
  { key: "priority", label: "Priority", type: "number" },
45
- { key: "due_by", label: "Due by", type: "date", format: isoToDateInputValue },
45
+ { key: "due", label: "Due", type: "date", format: isoToDateInputValue },
46
46
  { key: "completed", label: "Completed", type: "checkbox" },
47
47
  // description goes last and is flagged `grow`: it's the big free-text
48
48
  // field, so it sits at the bottom and stretches to fill whatever panel
@@ -102,8 +102,8 @@ function handleFieldChange(item, def, value, immediate = false) {
102
102
  return;
103
103
  }
104
104
 
105
- if (def.key === "due_by") {
106
- if (!value) return; // clearing due_by isn't supported by the CLI today
105
+ if (def.key === "due") {
106
+ if (!value) return; // clearing due isn't supported by the CLI today
107
107
  value = dateInputValueToCliFormat(value);
108
108
  }
109
109
 
@@ -52,7 +52,7 @@ function buildTree(itemsById, id, seen = new Set()) {
52
52
  size: STYLE.node.size,
53
53
  completed: item.completed,
54
54
  status: item.status,
55
- due_by: item.due_by,
55
+ due: item.due,
56
56
  color: item.color,
57
57
  children,
58
58
  };
@@ -2,10 +2,11 @@
2
2
  // direction key moves to. Every direction has exactly one meaning, with no
3
3
  // fallbacks:
4
4
  // - a plain (non-chain) node: down/up are children/parent, as ever
5
- // - a chain member: down/up are chainNext/chainPrev — the chain is the
5
+ // - a chain member: down is chainNext, up is chainPrev — the chain is the
6
6
  // spine you walk, and its side subtrees are never entered by accident.
7
- // The one exception is up from the head of a chain that sits at the top
8
- // level of the forest, which goes to the synthetic root (show all)
7
+ // A chain's head has no chainPrev, so up from it leaves the chain for the
8
+ // head's tree parent (the synthetic root, i.e. show all, for a top-level
9
+ // chain)
9
10
  // - into: chain member -> its subtree (remembered/first child), else nothing
10
11
  // - out: jump to the nearest chain member whose subtree contains the
11
12
  // current node (starting from the head of the node's own chain, if it's
@@ -49,10 +50,11 @@ function navigateGraph(direction, cur, graph) {
49
50
  if (direction === "down") {
50
51
  target = inChain ? cur.chainNext : rememberedChild(cur);
51
52
  } else if (direction === "up") {
52
- // only a chain's head has a parent - a top-level one is the synthetic root
53
- target = inChain
54
- ? cur.chainPrev || (cur.parent === graph.rootNode ? graph.rootNode : null)
55
- : cur.parent;
53
+ // a node has either a chainPrev (a non-head chain member) or a tree
54
+ // parent (a chain's head, or any plain node), never both, so this never
55
+ // has to choose: walk back along the chain, and from its head leave it
56
+ // for the tree parent (the synthetic root for a top-level chain)
57
+ target = cur.chainPrev || cur.parent;
56
58
  } else if (direction === "into") {
57
59
  target = inChain ? rememberedChild(cur) : null;
58
60
  } else if (direction === "out") {
@@ -238,13 +238,13 @@ function drawNode(ctx, node, isHovered, isSelected) {
238
238
  ctx.fillText(statusLabel, nodeX + node.size - STYLE.node.idPadding, nodeY + STYLE.node.idPadding);
239
239
  }
240
240
 
241
- if (!isShadow && node.due_by) {
241
+ if (!isShadow && node.due) {
242
242
  ctx.fillStyle = STYLE.colors.dueText;
243
243
  ctx.font = STYLE.node.idFont;
244
244
  ctx.textAlign = "right";
245
245
  ctx.textBaseline = "bottom";
246
246
  // "2026-12-31T00:00:00" -> "12/31" - compact, locale-agnostic
247
- const dueLabel = node.due_by.slice(5, 7) + "/" + node.due_by.slice(8, 10);
247
+ const dueLabel = node.due.slice(5, 7) + "/" + node.due.slice(8, 10);
248
248
  ctx.fillText(dueLabel, nodeX + node.size - STYLE.node.idPadding, nodeY + node.size - STYLE.node.idPadding);
249
249
  }
250
250
 
@@ -1,3 +1,6 @@
1
+ # standard lib
2
+ from datetime import datetime
3
+
1
4
  # local
2
5
  from taskai.json_dir_database import JsonDirectoryDatabase, DatabaseError
3
6
  from taskai.models import TodoItem, Comment
@@ -5,6 +8,7 @@ from taskai.config import config
5
8
 
6
9
  # external
7
10
  from rich import print
11
+ from rich.color import Color, ColorParseError
8
12
  from rich.console import Console
9
13
 
10
14
 
@@ -26,7 +30,7 @@ VALID_ATTRS = {
26
30
  "id",
27
31
  "name",
28
32
  "created_on",
29
- "due_by",
33
+ "due",
30
34
  "priority",
31
35
  "status"
32
36
  }
@@ -61,13 +65,16 @@ def view_lists(
61
65
 
62
66
  def _render_display_string(item: TodoItem):
63
67
  display_string = ""
68
+ item_color = _rich_color(item.color)
64
69
  for i, attr in enumerate(attrs):
65
70
  part = getattr(item, attr)
66
71
  if not part:
67
72
  continue
68
- part = str(part)
69
- if display_colors and colors[i] != "_":
70
- part = _wrap_string(part, f"[{colors[i]}]", f"[/{colors[i]}]")
73
+ part = _format_date(part) if isinstance(part, datetime) else str(part)
74
+ # an item's own color beats the column colors: it's drawn entirely in it
75
+ color = item_color or (colors[i] if display_colors and colors[i] != "_" else None)
76
+ if color:
77
+ part = _wrap_string(part, f"[{color}]", f"[/{color}]")
71
78
  display_string += f" {part}"
72
79
  display_string = _wrap_string(display_string, "[strike]", "[/strike]", condition=item.completed)
73
80
  return display_string
@@ -131,9 +138,11 @@ def view_item(
131
138
  item = db.get_item(item_id)
132
139
 
133
140
  console = Console()
134
- console.print(f"[bold green]Name:[/bold green] {item.name}")
135
- if item.due_by:
136
- console.print(f"[bold green]Due By:[/bold green] {item.due_by or ""}")
141
+ item_color = _rich_color(item.color)
142
+ name = _wrap_string(item.name, f"[{item_color}]", f"[/{item_color}]", condition=bool(item_color))
143
+ console.print(f"[bold green]Name:[/bold green] {name}")
144
+ if item.due:
145
+ console.print(f"[bold green]Due:[/bold green] {_format_date(item.due)}")
137
146
 
138
147
  if item.description:
139
148
  console.print(f"\n[bold green]Description:[/bold green]\n{item.description}")
@@ -142,7 +151,7 @@ def view_item(
142
151
  console.print("\n[bold green]\nComments:[/bold green]")
143
152
  for comment_id in item.comment_ids:
144
153
  comment: Comment = db.get_comment(comment_id)
145
- console.print(f"{comment.created_on.strftime("%Y-%m-%d %H:%M:%S")} - {comment.content}")
154
+ console.print(f"{_format_date(comment.created_on)} - {comment.content}")
146
155
 
147
156
  if item.child_ids:
148
157
  console.print("\n[bold green]\nSubtasks:[/bold green]")
@@ -158,6 +167,40 @@ def view_item(
158
167
 
159
168
  ### Utils
160
169
 
170
+ # CSS color names Rich has no name for (its named colors come from the 256-color
171
+ # ANSI palette), so an item colored "orange" in the browser can still be shown
172
+ _CSS_COLORS_MISSING_IN_RICH = {
173
+ "orange": "#ffa500", "pink": "#ffc0cb", "gold": "#ffd700", "teal": "#008080",
174
+ "brown": "#a52a2a", "indigo": "#4b0082", "violet": "#ee82ee", "crimson": "#dc143c",
175
+ "coral": "#ff7f50", "salmon": "#fa8072", "lime": "#00ff00", "olive": "#808000",
176
+ "navy": "#000080", "maroon": "#800000", "silver": "#c0c0c0", "gray": "#808080",
177
+ "grey": "#808080",
178
+ }
179
+
180
+
181
+ def _rich_color(css_color: str | None) -> str | None:
182
+ """An item's `color` (a CSS color string, e.g. "#ff8844" or "green") as a
183
+ Rich color, or None when it's unset or isn't one Rich can draw - an
184
+ unusable color just isn't shown rather than breaking the whole listing."""
185
+ if not css_color:
186
+ return None
187
+ css_color = css_color.strip().lower()
188
+ for candidate in (css_color, _CSS_COLORS_MISSING_IN_RICH.get(css_color)):
189
+ if not candidate:
190
+ continue
191
+ try:
192
+ Color.parse(candidate)
193
+ except ColorParseError:
194
+ continue
195
+ return candidate
196
+ return None
197
+
198
+
199
+ def _format_date(value: datetime) -> str:
200
+ """How every date is shown in the CLI: just the day, MM/DD/YY - the time
201
+ of day is never displayed."""
202
+ return value.strftime("%m/%d/%y")
203
+
161
204
 
162
205
  def _wrap_string(string_, before, after=None, condition=True):
163
206
  if after is None:
@@ -0,0 +1,73 @@
1
+ import sys, os; sys.path.append(os.getcwd())
2
+ import shutil
3
+
4
+ import orjson as json
5
+
6
+ from taskai.json_dir_database import JsonDirectoryDatabase
7
+
8
+ LEGACY_DIR = "_tmp_legacy_dir"
9
+
10
+
11
+ def _write_legacy_file(items: dict, config: dict | None = None):
12
+ os.makedirs(LEGACY_DIR, exist_ok=True)
13
+ data = {"name": "test_user", "id_counter": len(items), "todo_items": items, "config": config or {}}
14
+ with open(os.path.join(LEGACY_DIR, "test_user.json"), "wb") as f:
15
+ f.write(json.dumps(data))
16
+
17
+
18
+ def _connect() -> JsonDirectoryDatabase:
19
+ db = JsonDirectoryDatabase(LEGACY_DIR, "test_user")
20
+ db.connect()
21
+ return db
22
+
23
+
24
+ def _cleanup():
25
+ if os.path.exists(LEGACY_DIR):
26
+ shutil.rmtree(LEGACY_DIR)
27
+
28
+
29
+ def test_due_by_is_read_as_due():
30
+ _cleanup()
31
+ try:
32
+ _write_legacy_file({
33
+ "1": {"id": 1, "name": "old", "due_by": "2026-10-01T00:00:00"},
34
+ "2": {"id": 2, "name": "no due", "due_by": None},
35
+ "3": {"id": 3, "name": "both", "due_by": "2026-01-01T00:00:00", "due": "2026-02-02T00:00:00"},
36
+ })
37
+ db = _connect()
38
+
39
+ assert db.get_item(1).due.isoformat() == "2026-10-01T00:00:00"
40
+ assert db.get_item(2).due is None
41
+ # `due` wins when both are set - the legacy key never overwrites it
42
+ assert db.get_item(3).due.isoformat() == "2026-02-02T00:00:00"
43
+
44
+ # raw-dict reads see the new name too, and the legacy key is gone
45
+ assert db.get_item_attr(1, "due") == "2026-10-01T00:00:00"
46
+ assert all("due_by" not in item for item in db.user_data.todo_items.values())
47
+ finally:
48
+ _cleanup()
49
+
50
+
51
+ def test_migrated_data_is_written_back_under_the_new_name():
52
+ _cleanup()
53
+ try:
54
+ _write_legacy_file({"1": {"id": 1, "name": "old", "due_by": "2026-10-01T00:00:00"}})
55
+ db = _connect()
56
+ db.commit()
57
+
58
+ with open(os.path.join(LEGACY_DIR, "test_user.json"), "rb") as f:
59
+ saved = json.loads(f.read())
60
+ assert "due_by" not in saved["todo_items"]["1"]
61
+ assert saved["todo_items"]["1"]["due"] == "2026-10-01T00:00:00"
62
+ finally:
63
+ _cleanup()
64
+
65
+
66
+ def test_saved_display_string_is_migrated():
67
+ _cleanup()
68
+ try:
69
+ _write_legacy_file({}, config={"DISPLAY_STRING": "id name status due_by"})
70
+ db = _connect()
71
+ assert db.get_config().DISPLAY_STRING == "id name status due"
72
+ finally:
73
+ _cleanup()
@@ -24,7 +24,7 @@ def _populate_db():
24
24
  child1 = db.create_item(name="child 1", parent_id=root1, priority=3)
25
25
  root2 = db.create_item(name="root 2", description="Describe Marcellus Wallace")
26
26
  child2 = db.create_item(name="child 2", parent_id=root2, priority=2)
27
- child3 = db.create_item(name="child 3", parent_id=root2, due_by=datetime(2026,10, 1))
27
+ child3 = db.create_item(name="child 3", parent_id=root2, due=datetime(2026,10, 1))
28
28
  gchild1 = db.create_item(name="grandchild 1", parent_id=child3, completed=True, description="Say 'what' again.")
29
29
  db.create_comment("I dare you", item_id=gchild1)
30
30
  db.create_comment("I double dare you mothafucka, say 'what' one more goddamn time", item_id=gchild1)
File without changes
File without changes
File without changes
File without changes
File without changes