microsoft-todo-cli 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl

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.
@@ -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.
@@ -1,4 +1,4 @@
1
- microsoft_todo_cli-1.0.0.dist-info/licenses/LICENSE,sha256=1xYliB2ZJeYUr4HpX68WiziquLEEHtHs4diJdPCi7ec,1064
1
+ microsoft_todo_cli-1.1.0.dist-info/licenses/LICENSE,sha256=1xYliB2ZJeYUr4HpX68WiziquLEEHtHs4diJdPCi7ec,1064
2
2
  tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  tests/run_tests.py,sha256=gi0SgUUTBgav9WjSOmGYBH2pVIfxXokk3NXmrPfgxhQ,1651
4
4
  tests/test_checklist_cli.py,sha256=-JhpcXokmYdMKQXhn3FeIDg2xKzD7irfnfzSWjeKvpo,4497
@@ -11,27 +11,29 @@ tests/test_datetime_parser.py,sha256=eVurIgTf-P0g4wv09i4fJ3lrAqjB0K6i89UNMWi_9aA
11
11
  tests/test_filters.py,sha256=-uFVfyhIucQSLSiLhNvaUJNrW5Zuf92Sl0T6WltlJTs,4171
12
12
  tests/test_json_output.py,sha256=ZkZW6N3CuLBz0_j1fRk2nMJ_DN5b3QNdnFuUEgN3jQI,7411
13
13
  tests/test_lst_output.py,sha256=EksORiPZIfWS8u7lHm99tBNXAHtfavE8XJzgAp-uuwo,4533
14
- tests/test_models.py,sha256=uMhHJqWBVqiAFb8xL017z0vhyfrG7TH8C4ZUsgVcovI,8095
14
+ tests/test_models.py,sha256=mKzj5EC8ZyseQmWvyId3unkYikUyouApiYNDoPacC1Y,11354
15
+ tests/test_note_cli.py,sha256=KhTHZkTwGGSwA9KyTMOBgUtxlwstEu5YCXBE1ykq1Xo,4017
16
+ tests/test_note_wrapper.py,sha256=sKQu0VxlKsnae3FUr_E5hiRs065o78hX6QogGw846Es,1669
15
17
  tests/test_odata_escape.py,sha256=P6nECMeUn7UEKyZXkRqFjx3_EwoF4Q84b56aJ01lLPg,7976
16
18
  tests/test_recurrence.py,sha256=-pVYKrL5RvtsL3YhTJOfB92MTgKwDFz-0_1JImmlCYU,6386
17
19
  tests/test_update_command.py,sha256=Hq--wFbfcUtZ7s83Q8tQVZTFokraWxjqqKRFUG5307E,7740
18
20
  tests/test_utils.py,sha256=mXgtng8oB8f1NkUb8qITAM8rnJOHiBYa4zm3eYln6Q4,5003
19
21
  tests/test_wrapper.py,sha256=wlO3U4_L-LbMWyMPHPd6dMWOliHt_h7ZCoqF7dvSCvc,6546
20
- todocli/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
21
- todocli/cli.py,sha256=k4zLprrIJUjuD4kBrQhNE6Rns8s2lnQiY6d6wvkoRzU,45458
22
+ todocli/__init__.py,sha256=LGVQyDsWifdACo7qztwb8RWWHds1E7uQ-ZqD8SAjyw4,22
23
+ todocli/cli.py,sha256=Ypkyt83iHve1_fsdZl8ilKlK8V_g4-7cvCqpK0tOcgQ,50366
22
24
  todocli/graphapi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
25
  todocli/graphapi/oauth.py,sha256=AA4X3ZhmCLTEH3ZrbsihemcO7UUfFCzRUxko9tZOIo0,3993
24
- todocli/graphapi/wrapper.py,sha256=kWiaLrQwDBa6mBG_4qkJKuzzCxI0MGOk9UJurWf83vg,21155
26
+ todocli/graphapi/wrapper.py,sha256=vMRzToGk4gh2Eldgl50InkErGwvANdnaDAgXnJMnIA4,23062
25
27
  todocli/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
28
  todocli/models/checklistitem.py,sha256=KO1CZD8YvL7MqByOW4CQIGuDLyUM4DGCObs0ATpHXYo,1968
27
29
  todocli/models/todolist.py,sha256=StdyeA0rY-PDIst2-82FvgSgy_IxvzoqpOVUVJO6rmc,914
28
- todocli/models/todotask.py,sha256=zRK90WUEftaZfAMmA-ODVR5oE2UCQMevHs21PfAmsjQ,3328
30
+ todocli/models/todotask.py,sha256=_v5YTqXbZi1pLvowHpAeZblBsTkDLLzegyPmt5DADoY,3707
29
31
  todocli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
32
  todocli/utils/datetime_util.py,sha256=fcx1ZK-59Sn270avEenvFOiTOvSgTJPVGD8qe6LxxIg,10283
31
33
  todocli/utils/recurrence_util.py,sha256=a-Vcbr3YNPioOtHEMwQMViKGqq5qxwkzdX3gAdhI_Zw,3233
32
34
  todocli/utils/update_checker.py,sha256=X--k4dkSq3bhNvoLdPwDBjdu2atGJjCze7349zbAnDo,2049
33
- microsoft_todo_cli-1.0.0.dist-info/METADATA,sha256=kTbLk9-qCmXU_DQR1XZDOiHjQYTOGevU38bibpuOlbU,8807
34
- microsoft_todo_cli-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
35
- microsoft_todo_cli-1.0.0.dist-info/entry_points.txt,sha256=PqRWcETcJ1IRkrRYErjmilmH6y205R8VPX8QmgWWWGg,42
36
- microsoft_todo_cli-1.0.0.dist-info/top_level.txt,sha256=EIZMq91YloWqg-5RgfD9gxCiO-nGcrkciT9GnLyMDnc,14
37
- microsoft_todo_cli-1.0.0.dist-info/RECORD,,
35
+ microsoft_todo_cli-1.1.0.dist-info/METADATA,sha256=x0drE2xey928xKuGbYr_dvjSN88kycutBTozjqrQr7E,9434
36
+ microsoft_todo_cli-1.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
37
+ microsoft_todo_cli-1.1.0.dist-info/entry_points.txt,sha256=PqRWcETcJ1IRkrRYErjmilmH6y205R8VPX8QmgWWWGg,42
38
+ microsoft_todo_cli-1.1.0.dist-info/top_level.txt,sha256=EIZMq91YloWqg-5RgfD9gxCiO-nGcrkciT9GnLyMDnc,14
39
+ microsoft_todo_cli-1.1.0.dist-info/RECORD,,
tests/test_models.py CHANGED
@@ -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()
tests/test_note_cli.py ADDED
@@ -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()
todocli/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.0.0"
1
+ __version__ = "1.1.0"
todocli/cli.py CHANGED
@@ -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
  ),