microsoft-todo-cli 1.0.0__tar.gz → 1.2.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.2.0}/CHANGES.txt +9 -0
  2. {microsoft_todo_cli-1.0.0/microsoft_todo_cli.egg-info → microsoft_todo_cli-1.2.0}/PKG-INFO +19 -1
  3. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/README.md +18 -0
  4. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0/microsoft_todo_cli.egg-info}/PKG-INFO +19 -1
  5. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/microsoft_todo_cli.egg-info/SOURCES.txt +2 -0
  6. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_cli_commands.py +16 -0
  7. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_models.py +93 -0
  8. microsoft_todo_cli-1.2.0/tests/test_note_cli.py +104 -0
  9. microsoft_todo_cli-1.2.0/tests/test_note_wrapper.py +52 -0
  10. microsoft_todo_cli-1.2.0/todocli/__init__.py +1 -0
  11. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/todocli/cli.py +163 -0
  12. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/todocli/graphapi/wrapper.py +69 -0
  13. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/todocli/models/todotask.py +9 -0
  14. microsoft_todo_cli-1.0.0/todocli/__init__.py +0 -1
  15. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/LICENSE +0 -0
  16. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/MANIFEST.in +0 -0
  17. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/docs/setup-api.md +0 -0
  18. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/microsoft_todo_cli.egg-info/dependency_links.txt +0 -0
  19. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/microsoft_todo_cli.egg-info/entry_points.txt +0 -0
  20. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/microsoft_todo_cli.egg-info/requires.txt +0 -0
  21. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/microsoft_todo_cli.egg-info/top_level.txt +0 -0
  22. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/setup.cfg +0 -0
  23. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/setup.py +0 -0
  24. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/__init__.py +0 -0
  25. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/run_tests.py +0 -0
  26. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_checklist_cli.py +0 -0
  27. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_checklist_item_model.py +0 -0
  28. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_checklist_wrapper.py +0 -0
  29. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_cli_output.py +0 -0
  30. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_cli_url_integration.py +0 -0
  31. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_datetime_parser.py +0 -0
  32. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_filters.py +0 -0
  33. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_json_output.py +0 -0
  34. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_lst_output.py +0 -0
  35. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_odata_escape.py +0 -0
  36. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_recurrence.py +0 -0
  37. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_update_command.py +0 -0
  38. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_utils.py +0 -0
  39. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/tests/test_wrapper.py +0 -0
  40. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/todocli/graphapi/__init__.py +0 -0
  41. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/todocli/graphapi/oauth.py +0 -0
  42. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/todocli/models/__init__.py +0 -0
  43. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/todocli/models/checklistitem.py +0 -0
  44. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/todocli/models/todolist.py +0 -0
  45. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/todocli/utils/__init__.py +0 -0
  46. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/todocli/utils/datetime_util.py +0 -0
  47. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/todocli/utils/recurrence_util.py +0 -0
  48. {microsoft_todo_cli-1.0.0 → microsoft_todo_cli-1.2.0}/todocli/utils/update_checker.py +0 -0
@@ -1,3 +1,12 @@
1
+ v1.2.0, 2026-02-05
2
+ - Added -N/--note flag to 'new' command for creating tasks with notes
3
+
4
+ v1.1.0, 2026-02-05
5
+ - Added note support: note, show-note, clear-note commands
6
+ - Added sn, cn aliases for show-note and clear-note
7
+ - Notes are now displayed in show command and JSON output
8
+ - Added note field to Task model
9
+
1
10
  v1.0.0, 2026-02-04
2
11
  - Fork from kiblee/tod0 with redesigned CLI
3
12
  - 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.2.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
@@ -116,6 +116,7 @@ todo new "Task" -I # Important
116
116
  todo new "Task" -R daily # Recurring
117
117
  todo new "Task" -R weekly:mon,fri # Recurring on specific days
118
118
  todo new "Task" -S "Step 1" -S "Step 2" # With subtasks
119
+ todo new "Task" -N "Note content" # With note
119
120
 
120
121
  # View single task
121
122
  todo show "Task" # Show task details
@@ -141,6 +142,16 @@ todo uncomplete-step "Task" "Step" # Uncheck
141
142
  todo rm-step "Task" 0 # Remove by index
142
143
  ```
143
144
 
145
+ ### Notes
146
+
147
+ ```bash
148
+ todo note "Task" "Note content" # Add/update note
149
+ todo show-note "Task" # Display note (alias: sn)
150
+ todo clear-note "Task" # Remove note (alias: cn)
151
+ ```
152
+
153
+ Notes are text content attached to a task. Use `todo show "Task"` to see the note along with other task details.
154
+
144
155
  ### Lists
145
156
 
146
157
  ```bash
@@ -279,6 +290,7 @@ With `--json`: stdout contains only valid JSON on success. Errors go to stderr.
279
290
  | `t` | `tasks` | `d` | `rm` |
280
291
  | `n` | `new` | `newl` | `new-list` |
281
292
  | `c` | `complete` | `reopen` | `uncomplete` |
293
+ | `sn` | `show-note` | `cn` | `clear-note` |
282
294
 
283
295
  ```bash
284
296
  todo t # = todo tasks
@@ -287,6 +299,12 @@ todo c 0 1 2 # = todo complete 0 1 2
287
299
  todo d "Old" -y # = todo rm "Old" -y
288
300
  ```
289
301
 
302
+ ## Claude Code
303
+
304
+ A skill for [Claude Code](https://claude.ai/download) is available:
305
+
306
+ **[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.
307
+
290
308
  ## Credits
291
309
 
292
310
  Forked from [kiblee/tod0](https://github.com/kiblee/tod0) with a redesigned CLI.
@@ -78,6 +78,7 @@ todo new "Task" -I # Important
78
78
  todo new "Task" -R daily # Recurring
79
79
  todo new "Task" -R weekly:mon,fri # Recurring on specific days
80
80
  todo new "Task" -S "Step 1" -S "Step 2" # With subtasks
81
+ todo new "Task" -N "Note content" # With note
81
82
 
82
83
  # View single task
83
84
  todo show "Task" # Show task details
@@ -103,6 +104,16 @@ todo uncomplete-step "Task" "Step" # Uncheck
103
104
  todo rm-step "Task" 0 # Remove by index
104
105
  ```
105
106
 
107
+ ### Notes
108
+
109
+ ```bash
110
+ todo note "Task" "Note content" # Add/update note
111
+ todo show-note "Task" # Display note (alias: sn)
112
+ todo clear-note "Task" # Remove note (alias: cn)
113
+ ```
114
+
115
+ Notes are text content attached to a task. Use `todo show "Task"` to see the note along with other task details.
116
+
106
117
  ### Lists
107
118
 
108
119
  ```bash
@@ -241,6 +252,7 @@ With `--json`: stdout contains only valid JSON on success. Errors go to stderr.
241
252
  | `t` | `tasks` | `d` | `rm` |
242
253
  | `n` | `new` | `newl` | `new-list` |
243
254
  | `c` | `complete` | `reopen` | `uncomplete` |
255
+ | `sn` | `show-note` | `cn` | `clear-note` |
244
256
 
245
257
  ```bash
246
258
  todo t # = todo tasks
@@ -249,6 +261,12 @@ todo c 0 1 2 # = todo complete 0 1 2
249
261
  todo d "Old" -y # = todo rm "Old" -y
250
262
  ```
251
263
 
264
+ ## Claude Code
265
+
266
+ A skill for [Claude Code](https://claude.ai/download) is available:
267
+
268
+ **[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.
269
+
252
270
  ## Credits
253
271
 
254
272
  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.2.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
@@ -116,6 +116,7 @@ todo new "Task" -I # Important
116
116
  todo new "Task" -R daily # Recurring
117
117
  todo new "Task" -R weekly:mon,fri # Recurring on specific days
118
118
  todo new "Task" -S "Step 1" -S "Step 2" # With subtasks
119
+ todo new "Task" -N "Note content" # With note
119
120
 
120
121
  # View single task
121
122
  todo show "Task" # Show task details
@@ -141,6 +142,16 @@ todo uncomplete-step "Task" "Step" # Uncheck
141
142
  todo rm-step "Task" 0 # Remove by index
142
143
  ```
143
144
 
145
+ ### Notes
146
+
147
+ ```bash
148
+ todo note "Task" "Note content" # Add/update note
149
+ todo show-note "Task" # Display note (alias: sn)
150
+ todo clear-note "Task" # Remove note (alias: cn)
151
+ ```
152
+
153
+ Notes are text content attached to a task. Use `todo show "Task"` to see the note along with other task details.
154
+
144
155
  ### Lists
145
156
 
146
157
  ```bash
@@ -279,6 +290,7 @@ With `--json`: stdout contains only valid JSON on success. Errors go to stderr.
279
290
  | `t` | `tasks` | `d` | `rm` |
280
291
  | `n` | `new` | `newl` | `new-list` |
281
292
  | `c` | `complete` | `reopen` | `uncomplete` |
293
+ | `sn` | `show-note` | `cn` | `clear-note` |
282
294
 
283
295
  ```bash
284
296
  todo t # = todo tasks
@@ -287,6 +299,12 @@ todo c 0 1 2 # = todo complete 0 1 2
287
299
  todo d "Old" -y # = todo rm "Old" -y
288
300
  ```
289
301
 
302
+ ## Claude Code
303
+
304
+ A skill for [Claude Code](https://claude.ai/download) is available:
305
+
306
+ **[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.
307
+
290
308
  ## Credits
291
309
 
292
310
  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
@@ -129,6 +129,22 @@ class TestCLIArgumentParsing(unittest.TestCase):
129
129
  args = self.parser.parse_args(["new", "buy milk"])
130
130
  self.assertEqual(args.step, [])
131
131
 
132
+ def test_new_command_with_note_flag(self):
133
+ """Test 'new' command with -N flag"""
134
+ args = self.parser.parse_args(["new", "-N", "Remember to check prices", "buy milk"])
135
+ self.assertEqual(args.task_name, "buy milk")
136
+ self.assertEqual(args.note, "Remember to check prices")
137
+
138
+ def test_new_command_with_note_long_flag(self):
139
+ """Test 'new' command with --note flag"""
140
+ args = self.parser.parse_args(["new", "--note", "My note", "buy milk"])
141
+ self.assertEqual(args.note, "My note")
142
+
143
+ def test_new_command_without_note(self):
144
+ """Test 'new' command defaults note to None"""
145
+ args = self.parser.parse_args(["new", "buy milk"])
146
+ self.assertIsNone(args.note)
147
+
132
148
  def test_new_command_with_all_flags(self):
133
149
  """Test 'new' command with all flags"""
134
150
  args = self.parser.parse_args(
@@ -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.2.0"
@@ -152,6 +152,7 @@ def new(args):
152
152
  due_datetime = parse_datetime(due_date_time_str)
153
153
 
154
154
  recurrence = parse_recurrence(args.recurrence)
155
+ note_content = getattr(args, "note", None)
155
156
 
156
157
  task_id = wrapper.create_task(
157
158
  name,
@@ -160,6 +161,7 @@ def new(args):
160
161
  due_datetime=due_datetime,
161
162
  important=args.important,
162
163
  recurrence=recurrence,
164
+ note=note_content,
163
165
  )
164
166
 
165
167
  steps = getattr(args, "step", []) or []
@@ -175,6 +177,8 @@ def new(args):
175
177
  msg = f"Created task '{name}' in '{task_list}'"
176
178
  if steps:
177
179
  msg += f" with {len(steps)} step(s)"
180
+ if note_content:
181
+ msg += " with note"
178
182
 
179
183
  result = {
180
184
  "action": "created",
@@ -185,6 +189,8 @@ def new(args):
185
189
  }
186
190
  if step_ids:
187
191
  result["step_ids"] = step_ids
192
+ if note_content:
193
+ result["note"] = note_content
188
194
 
189
195
  _output_result(args, result)
190
196
 
@@ -837,6 +843,122 @@ def rm_step(args):
837
843
  print(result["message"])
838
844
 
839
845
 
846
+ def note(args):
847
+ """Add or update a note on a task."""
848
+ task_id = getattr(args, "task_id", None)
849
+ use_json = getattr(args, "json", False)
850
+ note_content = args.note_content
851
+
852
+ if task_id:
853
+ list_name = getattr(args, "list", None) or "Tasks"
854
+ returned_id, title, content = wrapper.update_task_note(
855
+ note_content=note_content,
856
+ list_name=list_name,
857
+ task_id=task_id,
858
+ )
859
+ result = {
860
+ "action": "updated",
861
+ "id": returned_id,
862
+ "title": title,
863
+ "note": content,
864
+ "list": list_name,
865
+ "message": f"Updated note on task '{title}'",
866
+ }
867
+ else:
868
+ task_list, task_name = parse_task_path(
869
+ args.task_name, getattr(args, "list", None)
870
+ )
871
+ returned_id, title, content = wrapper.update_task_note(
872
+ note_content=note_content,
873
+ list_name=task_list,
874
+ task_name=try_parse_as_int(task_name),
875
+ )
876
+ result = {
877
+ "action": "updated",
878
+ "id": returned_id,
879
+ "title": title,
880
+ "note": content,
881
+ "list": task_list,
882
+ "message": f"Updated note on task '{title}' in '{task_list}'",
883
+ }
884
+
885
+ if use_json:
886
+ print(json.dumps(result, indent=2))
887
+ else:
888
+ print(result["message"])
889
+
890
+
891
+ def show_note(args):
892
+ """Display the note of a task."""
893
+ task_id = getattr(args, "task_id", None)
894
+ use_json = getattr(args, "json", False)
895
+
896
+ if task_id:
897
+ task_list = getattr(args, "list", None) or "Tasks"
898
+ task = wrapper.get_task(list_name=task_list, task_id=task_id)
899
+ else:
900
+ task_list, task_name = parse_task_path(
901
+ args.task_name, getattr(args, "list", None)
902
+ )
903
+ task = wrapper.get_task(
904
+ list_name=task_list, task_name=try_parse_as_int(task_name)
905
+ )
906
+
907
+ if use_json:
908
+ output = {
909
+ "id": task.id,
910
+ "title": task.title,
911
+ "note": task.note if task.note else None,
912
+ "list": task_list,
913
+ }
914
+ print(json.dumps(output, indent=2))
915
+ else:
916
+ if task.note:
917
+ print(task.note)
918
+ else:
919
+ print(f"No note on task '{task.title}'")
920
+
921
+
922
+ def clear_note(args):
923
+ """Clear the note from a task."""
924
+ task_id = getattr(args, "task_id", None)
925
+ use_json = getattr(args, "json", False)
926
+
927
+ if task_id:
928
+ list_name = getattr(args, "list", None) or "Tasks"
929
+ returned_id, title = wrapper.clear_task_note(
930
+ list_name=list_name,
931
+ task_id=task_id,
932
+ )
933
+ result = {
934
+ "action": "cleared",
935
+ "id": returned_id,
936
+ "title": title,
937
+ "list": list_name,
938
+ "message": f"Cleared note from task '{title}'",
939
+ }
940
+ else:
941
+ task_list, task_name = parse_task_path(
942
+ args.task_name, getattr(args, "list", None)
943
+ )
944
+ returned_id, title = wrapper.clear_task_note(
945
+ list_name=task_list,
946
+ task_name=try_parse_as_int(task_name),
947
+ )
948
+ result = {
949
+ "action": "cleared",
950
+ "id": returned_id,
951
+ "title": title,
952
+ "list": task_list,
953
+ "message": f"Cleared note from task '{title}' in '{task_list}'",
954
+ }
955
+
956
+ if use_json:
957
+ print(json.dumps(result, indent=2))
958
+ else:
959
+ print(result["message"])
960
+
961
+
840
962
  def show(args):
841
963
  """Display all details of a task."""
842
964
  task_id = getattr(args, "task_id", None)
@@ -875,6 +997,8 @@ def show(args):
875
997
  if task.reminder_datetime:
876
998
  print(f"Reminder: {task.reminder_datetime.strftime('%Y-%m-%d %H:%M')}")
877
999
  print(f"Created: {format_date(task.created_datetime, date_fmt)}")
1000
+ if task.note:
1001
+ print(f"Note: {task.note}")
878
1002
  if steps:
879
1003
  print("Steps:")
880
1004
  for i, step in enumerate(steps):
@@ -1082,6 +1206,12 @@ def setup_parser():
1082
1206
  default=[],
1083
1207
  help="Add a step (checklist item); can be repeated",
1084
1208
  )
1209
+ subparser.add_argument(
1210
+ "-N",
1211
+ "--note",
1212
+ help="Add a note to the task",
1213
+ metavar="TEXT",
1214
+ )
1085
1215
  _add_list_flag(subparser)
1086
1216
  _add_json_flag(subparser)
1087
1217
  subparser.set_defaults(func=new)
@@ -1272,6 +1402,39 @@ def setup_parser():
1272
1402
  _add_json_flag(subparser)
1273
1403
  subparser.set_defaults(func=rm_step)
1274
1404
 
1405
+ # 'note' command - add or update a note on a task
1406
+ subparser = subparsers.add_parser("note", help="Add or update a note on a task")
1407
+ subparser.add_argument("task_name", nargs="?", help=helptext_task_name)
1408
+ subparser.add_argument("note_content", help="Note content to add to the task")
1409
+ _add_list_flag(subparser)
1410
+ _add_id_flag(subparser)
1411
+ _add_json_flag(subparser)
1412
+ subparser.set_defaults(func=note)
1413
+
1414
+ # 'show-note' command (primary) and 'sn' alias
1415
+ for cmd_name in ["show-note", "sn"]:
1416
+ subparser = subparsers.add_parser(
1417
+ cmd_name,
1418
+ help="Display the note of a task" if cmd_name == "show-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=show_note)
1425
+
1426
+ # 'clear-note' command (primary) and 'cn' alias
1427
+ for cmd_name in ["clear-note", "cn"]:
1428
+ subparser = subparsers.add_parser(
1429
+ cmd_name,
1430
+ help="Clear the note from a task" if cmd_name == "clear-note" else argparse.SUPPRESS,
1431
+ )
1432
+ subparser.add_argument("task_name", nargs="?", help=helptext_task_name)
1433
+ _add_list_flag(subparser)
1434
+ _add_id_flag(subparser)
1435
+ _add_json_flag(subparser)
1436
+ subparser.set_defaults(func=clear_note)
1437
+
1275
1438
  return parser
1276
1439
 
1277
1440
 
@@ -172,6 +172,7 @@ def create_task(
172
172
  due_datetime: datetime | None = None,
173
173
  important: bool = False,
174
174
  recurrence: dict | None = None,
175
+ note: str | None = None,
175
176
  ):
176
177
  _require_list(list_name, list_id)
177
178
 
@@ -191,6 +192,8 @@ def create_task(
191
192
  "importance": TaskImportance.HIGH if important else TaskImportance.NORMAL,
192
193
  "recurrence": recurrence,
193
194
  }
195
+ if note:
196
+ request_body["body"] = {"content": note, "contentType": "text"}
194
197
  session = get_oauth_session()
195
198
  response = session.post(endpoint, json=request_body)
196
199
  if response.ok:
@@ -658,3 +661,69 @@ def get_step_id(
658
661
  raise StepNotFoundByName(step_name, task_name)
659
662
  else:
660
663
  raise TypeError(f"step_name must be str or int, got {type(step_name).__name__}")
664
+
665
+
666
+ # --- Note functions ---
667
+
668
+
669
+ def update_task_note(
670
+ note_content: str,
671
+ list_name: str = None,
672
+ task_name: Union[str, int] = None,
673
+ list_id: str = None,
674
+ task_id: str = None,
675
+ content_type: str = "text",
676
+ ):
677
+ """Update the note (body) of a task. Returns (task_id, task_title, note_content)."""
678
+ _require_list(list_name, list_id)
679
+ _require_task(task_name, task_id)
680
+
681
+ if list_id is None:
682
+ list_id = get_list_id_by_name(list_name)
683
+ if task_id is None:
684
+ task_id = get_task_id_by_name(list_name, task_name)
685
+
686
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
687
+ request_body = {
688
+ "body": {
689
+ "content": note_content,
690
+ "contentType": content_type,
691
+ }
692
+ }
693
+ session = get_oauth_session()
694
+ response = session.patch(endpoint, json=request_body)
695
+ if response.ok:
696
+ data = json.loads(response.content.decode())
697
+ body = data.get("body", {})
698
+ return task_id, data.get("title", ""), body.get("content", "")
699
+ response.raise_for_status()
700
+
701
+
702
+ def clear_task_note(
703
+ list_name: str = None,
704
+ task_name: Union[str, int] = None,
705
+ list_id: str = None,
706
+ task_id: str = None,
707
+ ):
708
+ """Clear the note (body) of a task. Returns (task_id, task_title)."""
709
+ _require_list(list_name, list_id)
710
+ _require_task(task_name, task_id)
711
+
712
+ if list_id is None:
713
+ list_id = get_list_id_by_name(list_name)
714
+ if task_id is None:
715
+ task_id = get_task_id_by_name(list_name, task_name)
716
+
717
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
718
+ request_body = {
719
+ "body": {
720
+ "content": "",
721
+ "contentType": "text",
722
+ }
723
+ }
724
+ session = get_oauth_session()
725
+ response = session.patch(endpoint, json=request_body)
726
+ if response.ok:
727
+ data = json.loads(response.content.decode())
728
+ return task_id, data.get("title", "")
729
+ 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"