microsoft-todo-cli 1.0.0__tar.gz → 1.1.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 (48) hide show
  1. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/CHANGES.txt +6 -0
  2. {microsoft_todo_cli-1.0.0/microsoft_todo_cli.egg-info → microsoft_todo_cli-1.1.0}/PKG-INFO +18 -1
  3. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/README.md +17 -0
  4. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0/microsoft_todo_cli.egg-info}/PKG-INFO +18 -1
  5. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/microsoft_todo_cli.egg-info/SOURCES.txt +2 -0
  6. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_models.py +93 -0
  7. microsoft_todo_cli-1.1.0/tests/test_note_cli.py +104 -0
  8. microsoft_todo_cli-1.1.0/tests/test_note_wrapper.py +52 -0
  9. microsoft_todo_cli-1.1.0/todocli/__init__.py +1 -0
  10. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/todocli/cli.py +151 -0
  11. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/todocli/graphapi/wrapper.py +66 -0
  12. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/todocli/models/todotask.py +9 -0
  13. microsoft_todo_cli-1.0.0/todocli/__init__.py +0 -1
  14. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/LICENSE +0 -0
  15. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/MANIFEST.in +0 -0
  16. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/docs/setup-api.md +0 -0
  17. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/microsoft_todo_cli.egg-info/dependency_links.txt +0 -0
  18. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/microsoft_todo_cli.egg-info/entry_points.txt +0 -0
  19. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/microsoft_todo_cli.egg-info/requires.txt +0 -0
  20. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/microsoft_todo_cli.egg-info/top_level.txt +0 -0
  21. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/setup.cfg +0 -0
  22. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/setup.py +0 -0
  23. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/__init__.py +0 -0
  24. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/run_tests.py +0 -0
  25. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_checklist_cli.py +0 -0
  26. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_checklist_item_model.py +0 -0
  27. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_checklist_wrapper.py +0 -0
  28. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_cli_commands.py +0 -0
  29. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_cli_output.py +0 -0
  30. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_cli_url_integration.py +0 -0
  31. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_datetime_parser.py +0 -0
  32. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_filters.py +0 -0
  33. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_json_output.py +0 -0
  34. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_lst_output.py +0 -0
  35. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_odata_escape.py +0 -0
  36. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_recurrence.py +0 -0
  37. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_update_command.py +0 -0
  38. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_utils.py +0 -0
  39. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/tests/test_wrapper.py +0 -0
  40. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/todocli/graphapi/__init__.py +0 -0
  41. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/todocli/graphapi/oauth.py +0 -0
  42. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/todocli/models/__init__.py +0 -0
  43. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/todocli/models/checklistitem.py +0 -0
  44. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/todocli/models/todolist.py +0 -0
  45. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/todocli/utils/__init__.py +0 -0
  46. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/todocli/utils/datetime_util.py +0 -0
  47. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/todocli/utils/recurrence_util.py +0 -0
  48. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.1.0}/todocli/utils/update_checker.py +0 -0
@@ -1,3 +1,9 @@
1
+ v1.1.0, 2026-02-05
2
+ - Added note support: note, show-note, clear-note commands
3
+ - Added sn, cn aliases for show-note and clear-note
4
+ - Notes are now displayed in show command and JSON output
5
+ - Added note field to Task model
6
+
1
7
  v1.0.0, 2026-02-04
2
8
  - Fork from kiblee/tod0 with redesigned CLI
3
9
  - Removed interactive TUI, CLI-only
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microsoft-todo-cli
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Fast, minimal command-line client for Microsoft To-Do
5
5
  Home-page: https://github.com/underwear/microsoft-todo-cli
6
6
  Author: underwear
@@ -141,6 +141,16 @@ todo uncomplete-step "Task" "Step" # Uncheck
141
141
  todo rm-step "Task" 0 # Remove by index
142
142
  ```
143
143
 
144
+ ### Notes
145
+
146
+ ```bash
147
+ todo note "Task" "Note content" # Add/update note
148
+ todo show-note "Task" # Display note (alias: sn)
149
+ todo clear-note "Task" # Remove note (alias: cn)
150
+ ```
151
+
152
+ Notes are text content attached to a task. Use `todo show "Task"` to see the note along with other task details.
153
+
144
154
  ### Lists
145
155
 
146
156
  ```bash
@@ -279,6 +289,7 @@ With `--json`: stdout contains only valid JSON on success. Errors go to stderr.
279
289
  | `t` | `tasks` | `d` | `rm` |
280
290
  | `n` | `new` | `newl` | `new-list` |
281
291
  | `c` | `complete` | `reopen` | `uncomplete` |
292
+ | `sn` | `show-note` | `cn` | `clear-note` |
282
293
 
283
294
  ```bash
284
295
  todo t # = todo tasks
@@ -287,6 +298,12 @@ todo c 0 1 2 # = todo complete 0 1 2
287
298
  todo d "Old" -y # = todo rm "Old" -y
288
299
  ```
289
300
 
301
+ ## Claude Code
302
+
303
+ A skill for [Claude Code](https://claude.ai/download) is available:
304
+
305
+ **[todo skill](https://github.com/underwear/claude-code-underwear-skills/blob/main/skills/todo/SKILL.md)** — enables Claude to manage your Microsoft To-Do tasks directly.
306
+
290
307
  ## Credits
291
308
 
292
309
  Forked from [kiblee/tod0](https://github.com/kiblee/tod0) with a redesigned CLI.
@@ -103,6 +103,16 @@ todo uncomplete-step "Task" "Step" # Uncheck
103
103
  todo rm-step "Task" 0 # Remove by index
104
104
  ```
105
105
 
106
+ ### Notes
107
+
108
+ ```bash
109
+ todo note "Task" "Note content" # Add/update note
110
+ todo show-note "Task" # Display note (alias: sn)
111
+ todo clear-note "Task" # Remove note (alias: cn)
112
+ ```
113
+
114
+ Notes are text content attached to a task. Use `todo show "Task"` to see the note along with other task details.
115
+
106
116
  ### Lists
107
117
 
108
118
  ```bash
@@ -241,6 +251,7 @@ With `--json`: stdout contains only valid JSON on success. Errors go to stderr.
241
251
  | `t` | `tasks` | `d` | `rm` |
242
252
  | `n` | `new` | `newl` | `new-list` |
243
253
  | `c` | `complete` | `reopen` | `uncomplete` |
254
+ | `sn` | `show-note` | `cn` | `clear-note` |
244
255
 
245
256
  ```bash
246
257
  todo t # = todo tasks
@@ -249,6 +260,12 @@ todo c 0 1 2 # = todo complete 0 1 2
249
260
  todo d "Old" -y # = todo rm "Old" -y
250
261
  ```
251
262
 
263
+ ## Claude Code
264
+
265
+ A skill for [Claude Code](https://claude.ai/download) is available:
266
+
267
+ **[todo skill](https://github.com/underwear/claude-code-underwear-skills/blob/main/skills/todo/SKILL.md)** — enables Claude to manage your Microsoft To-Do tasks directly.
268
+
252
269
  ## Credits
253
270
 
254
271
  Forked from [kiblee/tod0](https://github.com/kiblee/tod0) with a redesigned CLI.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microsoft-todo-cli
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Fast, minimal command-line client for Microsoft To-Do
5
5
  Home-page: https://github.com/underwear/microsoft-todo-cli
6
6
  Author: underwear
@@ -141,6 +141,16 @@ todo uncomplete-step "Task" "Step" # Uncheck
141
141
  todo rm-step "Task" 0 # Remove by index
142
142
  ```
143
143
 
144
+ ### Notes
145
+
146
+ ```bash
147
+ todo note "Task" "Note content" # Add/update note
148
+ todo show-note "Task" # Display note (alias: sn)
149
+ todo clear-note "Task" # Remove note (alias: cn)
150
+ ```
151
+
152
+ Notes are text content attached to a task. Use `todo show "Task"` to see the note along with other task details.
153
+
144
154
  ### Lists
145
155
 
146
156
  ```bash
@@ -279,6 +289,7 @@ With `--json`: stdout contains only valid JSON on success. Errors go to stderr.
279
289
  | `t` | `tasks` | `d` | `rm` |
280
290
  | `n` | `new` | `newl` | `new-list` |
281
291
  | `c` | `complete` | `reopen` | `uncomplete` |
292
+ | `sn` | `show-note` | `cn` | `clear-note` |
282
293
 
283
294
  ```bash
284
295
  todo t # = todo tasks
@@ -287,6 +298,12 @@ todo c 0 1 2 # = todo complete 0 1 2
287
298
  todo d "Old" -y # = todo rm "Old" -y
288
299
  ```
289
300
 
301
+ ## Claude Code
302
+
303
+ A skill for [Claude Code](https://claude.ai/download) is available:
304
+
305
+ **[todo skill](https://github.com/underwear/claude-code-underwear-skills/blob/main/skills/todo/SKILL.md)** — enables Claude to manage your Microsoft To-Do tasks directly.
306
+
290
307
  ## Credits
291
308
 
292
309
  Forked from [kiblee/tod0](https://github.com/kiblee/tod0) with a redesigned CLI.
@@ -23,6 +23,8 @@ tests/test_filters.py
23
23
  tests/test_json_output.py
24
24
  tests/test_lst_output.py
25
25
  tests/test_models.py
26
+ tests/test_note_cli.py
27
+ tests/test_note_wrapper.py
26
28
  tests/test_odata_escape.py
27
29
  tests/test_recurrence.py
28
30
  tests/test_update_command.py
@@ -230,6 +230,99 @@ class TestTaskModel(unittest.TestCase):
230
230
 
231
231
  self.assertIsNotNone(task.body_last_modified_datetime)
232
232
 
233
+ def test_task_with_note(self):
234
+ """Test task with note (body content)"""
235
+ api_response = {
236
+ "id": "task123",
237
+ "title": "Task with note",
238
+ "importance": "normal",
239
+ "status": "notStarted",
240
+ "createdDateTime": "2024-01-25T10:00:00.0000000Z",
241
+ "lastModifiedDateTime": "2024-01-25T11:00:00.0000000Z",
242
+ "isReminderOn": False,
243
+ "body": {
244
+ "content": "This is my note content",
245
+ "contentType": "text",
246
+ },
247
+ }
248
+ task = Task(api_response)
249
+
250
+ self.assertEqual(task.note, "This is my note content")
251
+ self.assertEqual(task.note_content_type, "text")
252
+
253
+ def test_task_without_note(self):
254
+ """Test task without note (no body field)"""
255
+ api_response = {
256
+ "id": "task123",
257
+ "title": "Task without note",
258
+ "importance": "normal",
259
+ "status": "notStarted",
260
+ "createdDateTime": "2024-01-25T10:00:00.0000000Z",
261
+ "lastModifiedDateTime": "2024-01-25T11:00:00.0000000Z",
262
+ "isReminderOn": False,
263
+ }
264
+ task = Task(api_response)
265
+
266
+ self.assertEqual(task.note, "")
267
+ self.assertEqual(task.note_content_type, "text")
268
+
269
+ def test_task_with_empty_note(self):
270
+ """Test task with empty note (body with empty content)"""
271
+ api_response = {
272
+ "id": "task123",
273
+ "title": "Task with empty note",
274
+ "importance": "normal",
275
+ "status": "notStarted",
276
+ "createdDateTime": "2024-01-25T10:00:00.0000000Z",
277
+ "lastModifiedDateTime": "2024-01-25T11:00:00.0000000Z",
278
+ "isReminderOn": False,
279
+ "body": {
280
+ "content": "",
281
+ "contentType": "text",
282
+ },
283
+ }
284
+ task = Task(api_response)
285
+
286
+ self.assertEqual(task.note, "")
287
+
288
+ def test_task_note_in_to_dict(self):
289
+ """Test that note is included in to_dict output"""
290
+ api_response = {
291
+ "id": "task123",
292
+ "title": "Task with note",
293
+ "importance": "normal",
294
+ "status": "notStarted",
295
+ "createdDateTime": "2024-01-25T10:00:00.0000000Z",
296
+ "lastModifiedDateTime": "2024-01-25T11:00:00.0000000Z",
297
+ "isReminderOn": False,
298
+ "body": {
299
+ "content": "Note for JSON",
300
+ "contentType": "text",
301
+ },
302
+ }
303
+ task = Task(api_response)
304
+ task_dict = task.to_dict()
305
+
306
+ self.assertIn("note", task_dict)
307
+ self.assertEqual(task_dict["note"], "Note for JSON")
308
+
309
+ def test_task_empty_note_in_to_dict(self):
310
+ """Test that empty note is None in to_dict output"""
311
+ api_response = {
312
+ "id": "task123",
313
+ "title": "Task without note",
314
+ "importance": "normal",
315
+ "status": "notStarted",
316
+ "createdDateTime": "2024-01-25T10:00:00.0000000Z",
317
+ "lastModifiedDateTime": "2024-01-25T11:00:00.0000000Z",
318
+ "isReminderOn": False,
319
+ }
320
+ task = Task(api_response)
321
+ task_dict = task.to_dict()
322
+
323
+ self.assertIn("note", task_dict)
324
+ self.assertIsNone(task_dict["note"])
325
+
233
326
 
234
327
  if __name__ == "__main__":
235
328
  unittest.main()
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """Unit tests for note CLI commands (argument parsing)"""
3
+
4
+ import unittest
5
+ from todocli.cli import setup_parser
6
+
7
+
8
+ class TestNoteCLIArgParsing(unittest.TestCase):
9
+ """Test that CLI parsers correctly parse note command arguments"""
10
+
11
+ def setUp(self):
12
+ self.parser = setup_parser()
13
+
14
+ # note command
15
+
16
+ def test_note_basic(self):
17
+ """Test note command with task name and content"""
18
+ args = self.parser.parse_args(["note", "Buy groceries", "Remember to check prices"])
19
+ self.assertEqual(args.task_name, "Buy groceries")
20
+ self.assertEqual(args.note_content, "Remember to check prices")
21
+
22
+ def test_note_with_list_flag(self):
23
+ """Test note command with --list flag"""
24
+ args = self.parser.parse_args(
25
+ ["note", "Buy groceries", "Remember to check prices", "-l", "Shopping"]
26
+ )
27
+ self.assertEqual(args.task_name, "Buy groceries")
28
+ self.assertEqual(args.note_content, "Remember to check prices")
29
+ self.assertEqual(args.list, "Shopping")
30
+
31
+ def test_note_with_id_flag(self):
32
+ """Test note command with --id flag"""
33
+ args = self.parser.parse_args(
34
+ ["note", "--id", "task123", "This is a note"]
35
+ )
36
+ self.assertEqual(args.task_id, "task123")
37
+ self.assertEqual(args.note_content, "This is a note")
38
+
39
+ def test_note_with_json_flag(self):
40
+ """Test note command with --json flag"""
41
+ args = self.parser.parse_args(
42
+ ["note", "Buy groceries", "Note content", "--json"]
43
+ )
44
+ self.assertTrue(args.json)
45
+
46
+ # show-note command
47
+
48
+ def test_show_note_basic(self):
49
+ """Test show-note command with task name"""
50
+ args = self.parser.parse_args(["show-note", "Buy groceries"])
51
+ self.assertEqual(args.task_name, "Buy groceries")
52
+
53
+ def test_show_note_with_list_flag(self):
54
+ """Test show-note command with --list flag"""
55
+ args = self.parser.parse_args(["show-note", "Buy groceries", "-l", "Shopping"])
56
+ self.assertEqual(args.task_name, "Buy groceries")
57
+ self.assertEqual(args.list, "Shopping")
58
+
59
+ def test_show_note_with_id_flag(self):
60
+ """Test show-note command with --id flag"""
61
+ args = self.parser.parse_args(["show-note", "--id", "task123"])
62
+ self.assertEqual(args.task_id, "task123")
63
+
64
+ def test_show_note_with_json_flag(self):
65
+ """Test show-note command with --json flag"""
66
+ args = self.parser.parse_args(["show-note", "Buy groceries", "--json"])
67
+ self.assertTrue(args.json)
68
+
69
+ def test_show_note_alias_sn(self):
70
+ """Test sn alias for show-note command"""
71
+ args = self.parser.parse_args(["sn", "Buy groceries"])
72
+ self.assertEqual(args.task_name, "Buy groceries")
73
+
74
+ # clear-note command
75
+
76
+ def test_clear_note_basic(self):
77
+ """Test clear-note command with task name"""
78
+ args = self.parser.parse_args(["clear-note", "Buy groceries"])
79
+ self.assertEqual(args.task_name, "Buy groceries")
80
+
81
+ def test_clear_note_with_list_flag(self):
82
+ """Test clear-note command with --list flag"""
83
+ args = self.parser.parse_args(["clear-note", "Buy groceries", "-l", "Shopping"])
84
+ self.assertEqual(args.task_name, "Buy groceries")
85
+ self.assertEqual(args.list, "Shopping")
86
+
87
+ def test_clear_note_with_id_flag(self):
88
+ """Test clear-note command with --id flag"""
89
+ args = self.parser.parse_args(["clear-note", "--id", "task123"])
90
+ self.assertEqual(args.task_id, "task123")
91
+
92
+ def test_clear_note_with_json_flag(self):
93
+ """Test clear-note command with --json flag"""
94
+ args = self.parser.parse_args(["clear-note", "Buy groceries", "--json"])
95
+ self.assertTrue(args.json)
96
+
97
+ def test_clear_note_alias_cn(self):
98
+ """Test cn alias for clear-note command"""
99
+ args = self.parser.parse_args(["cn", "Buy groceries"])
100
+ self.assertEqual(args.task_name, "Buy groceries")
101
+
102
+
103
+ if __name__ == "__main__":
104
+ unittest.main()
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """Unit tests for note wrapper functions"""
3
+
4
+ import unittest
5
+ from todocli.graphapi.wrapper import BASE_URL
6
+
7
+
8
+ class TestNoteEndpointPattern(unittest.TestCase):
9
+ """Test that the note endpoint URL is correctly constructed"""
10
+
11
+ def test_task_endpoint_for_note_update(self):
12
+ """Test the task endpoint follows the expected pattern for note updates"""
13
+ list_id = "list123"
14
+ task_id = "task456"
15
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
16
+
17
+ self.assertIn(list_id, endpoint)
18
+ self.assertIn(task_id, endpoint)
19
+ self.assertTrue(endpoint.startswith("https://graph.microsoft.com"))
20
+ self.assertIn("/tasks/", endpoint)
21
+
22
+ def test_note_request_body_format(self):
23
+ """Test that note request body has correct structure"""
24
+ note_content = "Test note content"
25
+ content_type = "text"
26
+
27
+ request_body = {
28
+ "body": {
29
+ "content": note_content,
30
+ "contentType": content_type,
31
+ }
32
+ }
33
+
34
+ self.assertIn("body", request_body)
35
+ self.assertEqual(request_body["body"]["content"], note_content)
36
+ self.assertEqual(request_body["body"]["contentType"], content_type)
37
+
38
+ def test_clear_note_request_body_format(self):
39
+ """Test that clear note request body has correct structure (empty content)"""
40
+ request_body = {
41
+ "body": {
42
+ "content": "",
43
+ "contentType": "text",
44
+ }
45
+ }
46
+
47
+ self.assertIn("body", request_body)
48
+ self.assertEqual(request_body["body"]["content"], "")
49
+
50
+
51
+ if __name__ == "__main__":
52
+ unittest.main()
@@ -0,0 +1 @@
1
+ __version__ = "1.1.0"
@@ -837,6 +837,122 @@ def rm_step(args):
837
837
  print(result["message"])
838
838
 
839
839
 
840
+ def note(args):
841
+ """Add or update a note on a task."""
842
+ task_id = getattr(args, "task_id", None)
843
+ use_json = getattr(args, "json", False)
844
+ note_content = args.note_content
845
+
846
+ if task_id:
847
+ list_name = getattr(args, "list", None) or "Tasks"
848
+ returned_id, title, content = wrapper.update_task_note(
849
+ note_content=note_content,
850
+ list_name=list_name,
851
+ task_id=task_id,
852
+ )
853
+ result = {
854
+ "action": "updated",
855
+ "id": returned_id,
856
+ "title": title,
857
+ "note": content,
858
+ "list": list_name,
859
+ "message": f"Updated note on task '{title}'",
860
+ }
861
+ else:
862
+ task_list, task_name = parse_task_path(
863
+ args.task_name, getattr(args, "list", None)
864
+ )
865
+ returned_id, title, content = wrapper.update_task_note(
866
+ note_content=note_content,
867
+ list_name=task_list,
868
+ task_name=try_parse_as_int(task_name),
869
+ )
870
+ result = {
871
+ "action": "updated",
872
+ "id": returned_id,
873
+ "title": title,
874
+ "note": content,
875
+ "list": task_list,
876
+ "message": f"Updated note on task '{title}' in '{task_list}'",
877
+ }
878
+
879
+ if use_json:
880
+ print(json.dumps(result, indent=2))
881
+ else:
882
+ print(result["message"])
883
+
884
+
885
+ def show_note(args):
886
+ """Display the note of a task."""
887
+ task_id = getattr(args, "task_id", None)
888
+ use_json = getattr(args, "json", False)
889
+
890
+ if task_id:
891
+ task_list = getattr(args, "list", None) or "Tasks"
892
+ task = wrapper.get_task(list_name=task_list, task_id=task_id)
893
+ else:
894
+ task_list, task_name = parse_task_path(
895
+ args.task_name, getattr(args, "list", None)
896
+ )
897
+ task = wrapper.get_task(
898
+ list_name=task_list, task_name=try_parse_as_int(task_name)
899
+ )
900
+
901
+ if use_json:
902
+ output = {
903
+ "id": task.id,
904
+ "title": task.title,
905
+ "note": task.note if task.note else None,
906
+ "list": task_list,
907
+ }
908
+ print(json.dumps(output, indent=2))
909
+ else:
910
+ if task.note:
911
+ print(task.note)
912
+ else:
913
+ print(f"No note on task '{task.title}'")
914
+
915
+
916
+ def clear_note(args):
917
+ """Clear the note from a task."""
918
+ task_id = getattr(args, "task_id", None)
919
+ use_json = getattr(args, "json", False)
920
+
921
+ if task_id:
922
+ list_name = getattr(args, "list", None) or "Tasks"
923
+ returned_id, title = wrapper.clear_task_note(
924
+ list_name=list_name,
925
+ task_id=task_id,
926
+ )
927
+ result = {
928
+ "action": "cleared",
929
+ "id": returned_id,
930
+ "title": title,
931
+ "list": list_name,
932
+ "message": f"Cleared note from task '{title}'",
933
+ }
934
+ else:
935
+ task_list, task_name = parse_task_path(
936
+ args.task_name, getattr(args, "list", None)
937
+ )
938
+ returned_id, title = wrapper.clear_task_note(
939
+ list_name=task_list,
940
+ task_name=try_parse_as_int(task_name),
941
+ )
942
+ result = {
943
+ "action": "cleared",
944
+ "id": returned_id,
945
+ "title": title,
946
+ "list": task_list,
947
+ "message": f"Cleared note from task '{title}' in '{task_list}'",
948
+ }
949
+
950
+ if use_json:
951
+ print(json.dumps(result, indent=2))
952
+ else:
953
+ print(result["message"])
954
+
955
+
840
956
  def show(args):
841
957
  """Display all details of a task."""
842
958
  task_id = getattr(args, "task_id", None)
@@ -875,6 +991,8 @@ def show(args):
875
991
  if task.reminder_datetime:
876
992
  print(f"Reminder: {task.reminder_datetime.strftime('%Y-%m-%d %H:%M')}")
877
993
  print(f"Created: {format_date(task.created_datetime, date_fmt)}")
994
+ if task.note:
995
+ print(f"Note: {task.note}")
878
996
  if steps:
879
997
  print("Steps:")
880
998
  for i, step in enumerate(steps):
@@ -1272,6 +1390,39 @@ def setup_parser():
1272
1390
  _add_json_flag(subparser)
1273
1391
  subparser.set_defaults(func=rm_step)
1274
1392
 
1393
+ # 'note' command - add or update a note on a task
1394
+ subparser = subparsers.add_parser("note", help="Add or update a note on a task")
1395
+ subparser.add_argument("task_name", nargs="?", help=helptext_task_name)
1396
+ subparser.add_argument("note_content", help="Note content to add to the task")
1397
+ _add_list_flag(subparser)
1398
+ _add_id_flag(subparser)
1399
+ _add_json_flag(subparser)
1400
+ subparser.set_defaults(func=note)
1401
+
1402
+ # 'show-note' command (primary) and 'sn' alias
1403
+ for cmd_name in ["show-note", "sn"]:
1404
+ subparser = subparsers.add_parser(
1405
+ cmd_name,
1406
+ help="Display the note of a task" if cmd_name == "show-note" else argparse.SUPPRESS,
1407
+ )
1408
+ subparser.add_argument("task_name", nargs="?", help=helptext_task_name)
1409
+ _add_list_flag(subparser)
1410
+ _add_id_flag(subparser)
1411
+ _add_json_flag(subparser)
1412
+ subparser.set_defaults(func=show_note)
1413
+
1414
+ # 'clear-note' command (primary) and 'cn' alias
1415
+ for cmd_name in ["clear-note", "cn"]:
1416
+ subparser = subparsers.add_parser(
1417
+ cmd_name,
1418
+ help="Clear the note from a task" if cmd_name == "clear-note" else argparse.SUPPRESS,
1419
+ )
1420
+ subparser.add_argument("task_name", nargs="?", help=helptext_task_name)
1421
+ _add_list_flag(subparser)
1422
+ _add_id_flag(subparser)
1423
+ _add_json_flag(subparser)
1424
+ subparser.set_defaults(func=clear_note)
1425
+
1275
1426
  return parser
1276
1427
 
1277
1428
 
@@ -658,3 +658,69 @@ def get_step_id(
658
658
  raise StepNotFoundByName(step_name, task_name)
659
659
  else:
660
660
  raise TypeError(f"step_name must be str or int, got {type(step_name).__name__}")
661
+
662
+
663
+ # --- Note functions ---
664
+
665
+
666
+ def update_task_note(
667
+ note_content: str,
668
+ list_name: str = None,
669
+ task_name: Union[str, int] = None,
670
+ list_id: str = None,
671
+ task_id: str = None,
672
+ content_type: str = "text",
673
+ ):
674
+ """Update the note (body) of a task. Returns (task_id, task_title, note_content)."""
675
+ _require_list(list_name, list_id)
676
+ _require_task(task_name, task_id)
677
+
678
+ if list_id is None:
679
+ list_id = get_list_id_by_name(list_name)
680
+ if task_id is None:
681
+ task_id = get_task_id_by_name(list_name, task_name)
682
+
683
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
684
+ request_body = {
685
+ "body": {
686
+ "content": note_content,
687
+ "contentType": content_type,
688
+ }
689
+ }
690
+ session = get_oauth_session()
691
+ response = session.patch(endpoint, json=request_body)
692
+ if response.ok:
693
+ data = json.loads(response.content.decode())
694
+ body = data.get("body", {})
695
+ return task_id, data.get("title", ""), body.get("content", "")
696
+ response.raise_for_status()
697
+
698
+
699
+ def clear_task_note(
700
+ list_name: str = None,
701
+ task_name: Union[str, int] = None,
702
+ list_id: str = None,
703
+ task_id: str = None,
704
+ ):
705
+ """Clear the note (body) of a task. Returns (task_id, task_title)."""
706
+ _require_list(list_name, list_id)
707
+ _require_task(task_name, task_id)
708
+
709
+ if list_id is None:
710
+ list_id = get_list_id_by_name(list_name)
711
+ if task_id is None:
712
+ task_id = get_task_id_by_name(list_name, task_name)
713
+
714
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
715
+ request_body = {
716
+ "body": {
717
+ "content": "",
718
+ "contentType": "text",
719
+ }
720
+ }
721
+ session = get_oauth_session()
722
+ response = session.patch(endpoint, json=request_body)
723
+ if response.ok:
724
+ data = json.loads(response.content.decode())
725
+ return task_id, data.get("title", "")
726
+ response.raise_for_status()
@@ -77,6 +77,14 @@ class Task:
77
77
  else:
78
78
  self.body_last_modified_datetime = None
79
79
 
80
+ # Note (body content)
81
+ if "body" in query_result and query_result["body"]:
82
+ self.note = query_result["body"].get("content", "")
83
+ self.note_content_type = query_result["body"].get("contentType", "text")
84
+ else:
85
+ self.note = ""
86
+ self.note_content_type = "text"
87
+
80
88
  def to_dict(self):
81
89
  """Convert task to dictionary for JSON serialization."""
82
90
  return {
@@ -85,6 +93,7 @@ class Task:
85
93
  "status": self.status.value,
86
94
  "importance": self.importance.value,
87
95
  "is_reminder_on": self.is_reminder_on,
96
+ "note": self.note if self.note else None,
88
97
  "created_datetime": (
89
98
  self.created_datetime.isoformat() if self.created_datetime else None
90
99
  ),
@@ -1 +0,0 @@
1
- __version__ = "1.0.0"