microsoft-todo-cli 1.0.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.
- microsoft_todo_cli-1.0.0.dist-info/METADATA +296 -0
- microsoft_todo_cli-1.0.0.dist-info/RECORD +37 -0
- microsoft_todo_cli-1.0.0.dist-info/WHEEL +5 -0
- microsoft_todo_cli-1.0.0.dist-info/entry_points.txt +2 -0
- microsoft_todo_cli-1.0.0.dist-info/licenses/LICENSE +22 -0
- microsoft_todo_cli-1.0.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/run_tests.py +39 -0
- tests/test_checklist_cli.py +118 -0
- tests/test_checklist_item_model.py +108 -0
- tests/test_checklist_wrapper.py +62 -0
- tests/test_cli_commands.py +436 -0
- tests/test_cli_output.py +169 -0
- tests/test_cli_url_integration.py +136 -0
- tests/test_datetime_parser.py +233 -0
- tests/test_filters.py +120 -0
- tests/test_json_output.py +223 -0
- tests/test_lst_output.py +135 -0
- tests/test_models.py +235 -0
- tests/test_odata_escape.py +175 -0
- tests/test_recurrence.py +159 -0
- tests/test_update_command.py +192 -0
- tests/test_utils.py +186 -0
- tests/test_wrapper.py +191 -0
- todocli/__init__.py +1 -0
- todocli/cli.py +1356 -0
- todocli/graphapi/__init__.py +0 -0
- todocli/graphapi/oauth.py +136 -0
- todocli/graphapi/wrapper.py +660 -0
- todocli/models/__init__.py +0 -0
- todocli/models/checklistitem.py +59 -0
- todocli/models/todolist.py +27 -0
- todocli/models/todotask.py +105 -0
- todocli/utils/__init__.py +0 -0
- todocli/utils/datetime_util.py +321 -0
- todocli/utils/recurrence_util.py +122 -0
- todocli/utils/update_checker.py +55 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""
|
|
2
|
+
For implementation details, refer to this source:
|
|
3
|
+
https://docs.microsoft.com/en-us/graph/api/resources/todo-overview?view=graph-rest-1.0
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Union
|
|
9
|
+
|
|
10
|
+
from todocli.models.todolist import TodoList
|
|
11
|
+
from todocli.models.todotask import Task, TaskImportance, TaskStatus
|
|
12
|
+
from todocli.models.checklistitem import ChecklistItem
|
|
13
|
+
from todocli.graphapi.oauth import get_oauth_session
|
|
14
|
+
|
|
15
|
+
from todocli.utils.datetime_util import datetime_to_api_timestamp
|
|
16
|
+
|
|
17
|
+
BASE_API = "https://graph.microsoft.com/v1.0"
|
|
18
|
+
BASE_RELATE_URL = "/me/todo/lists"
|
|
19
|
+
BASE_URL = f"{BASE_API}{BASE_RELATE_URL}"
|
|
20
|
+
BATCH_URL = f"{BASE_API}/$batch"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _require_list(list_name, list_id):
|
|
24
|
+
"""Validate that list_name or list_id is provided."""
|
|
25
|
+
if list_name is None and list_id is None:
|
|
26
|
+
raise ValueError("You must provide list_name or list_id")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _require_task(task_name, task_id):
|
|
30
|
+
"""Validate that task_name or task_id is provided."""
|
|
31
|
+
if task_name is None and task_id is None:
|
|
32
|
+
raise ValueError("You must provide task_name or task_id")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _require_step(step_name):
|
|
36
|
+
"""Validate that step_name is provided."""
|
|
37
|
+
if step_name is None:
|
|
38
|
+
raise ValueError("You must provide step_name")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ListNotFound(Exception):
|
|
42
|
+
def __init__(self, list_name):
|
|
43
|
+
self.message = "List with name '{}' could not be found".format(list_name)
|
|
44
|
+
super(ListNotFound, self).__init__(self.message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TaskNotFoundByName(Exception):
|
|
48
|
+
def __init__(self, task_name, list_name):
|
|
49
|
+
self.message = "Task with name '{}' could not be found in list '{}'".format(
|
|
50
|
+
task_name, list_name
|
|
51
|
+
)
|
|
52
|
+
super(TaskNotFoundByName, self).__init__(self.message)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TaskNotFoundByIndex(Exception):
|
|
56
|
+
def __init__(self, task_index, list_name):
|
|
57
|
+
self.message = "Task with index '{}' could not be found in list '{}'".format(
|
|
58
|
+
task_index, list_name
|
|
59
|
+
)
|
|
60
|
+
super(TaskNotFoundByIndex, self).__init__(self.message)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class StepNotFoundByName(Exception):
|
|
64
|
+
def __init__(self, step_name, task_name):
|
|
65
|
+
self.message = "Step with name '{}' could not be found in task '{}'".format(
|
|
66
|
+
step_name, task_name
|
|
67
|
+
)
|
|
68
|
+
super(StepNotFoundByName, self).__init__(self.message)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class StepNotFoundByIndex(Exception):
|
|
72
|
+
def __init__(self, step_index, task_name):
|
|
73
|
+
self.message = "Step with index '{}' could not be found in task '{}'".format(
|
|
74
|
+
step_index, task_name
|
|
75
|
+
)
|
|
76
|
+
super(StepNotFoundByIndex, self).__init__(self.message)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_response(response):
|
|
80
|
+
return json.loads(response.content.decode())["value"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_lists():
|
|
84
|
+
session = get_oauth_session()
|
|
85
|
+
response = session.get(BASE_URL)
|
|
86
|
+
response_value = parse_response(response)
|
|
87
|
+
return [TodoList(x) for x in response_value]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def create_list(title: str):
|
|
91
|
+
"""Create a new list. Returns (list_id, list_name)."""
|
|
92
|
+
request_body = {"displayName": title}
|
|
93
|
+
session = get_oauth_session()
|
|
94
|
+
response = session.post(BASE_URL, json=request_body)
|
|
95
|
+
if response.ok:
|
|
96
|
+
data = json.loads(response.content.decode())
|
|
97
|
+
return data.get("id", ""), data.get("displayName", "")
|
|
98
|
+
response.raise_for_status()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def rename_list(old_title: str, new_title: str):
|
|
102
|
+
"""Rename a list. Returns (list_id, new_title)."""
|
|
103
|
+
list_id = get_list_id_by_name(old_title)
|
|
104
|
+
request_body = {"displayName": new_title}
|
|
105
|
+
session = get_oauth_session()
|
|
106
|
+
response = session.patch(f"{BASE_URL}/{list_id}", json=request_body)
|
|
107
|
+
if response.ok:
|
|
108
|
+
data = json.loads(response.content.decode())
|
|
109
|
+
return data.get("id", ""), data.get("displayName", "")
|
|
110
|
+
response.raise_for_status()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def delete_list(list_name: str = None, list_id: str = None):
|
|
114
|
+
"""Delete a list. Returns list_id."""
|
|
115
|
+
_require_list(list_name, list_id)
|
|
116
|
+
|
|
117
|
+
if list_id is None:
|
|
118
|
+
list_id = get_list_id_by_name(list_name)
|
|
119
|
+
|
|
120
|
+
endpoint = f"{BASE_URL}/{list_id}"
|
|
121
|
+
session = get_oauth_session()
|
|
122
|
+
response = session.delete(endpoint)
|
|
123
|
+
if response.ok:
|
|
124
|
+
return list_id
|
|
125
|
+
response.raise_for_status()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_tasks(
|
|
129
|
+
list_name: str = None,
|
|
130
|
+
list_id: str = None,
|
|
131
|
+
num_tasks: int = 100,
|
|
132
|
+
include_completed: bool = False,
|
|
133
|
+
only_completed: bool = False,
|
|
134
|
+
):
|
|
135
|
+
"""Fetch tasks from a list.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
list_name: Name of the list
|
|
139
|
+
list_id: ID of the list (alternative to list_name)
|
|
140
|
+
num_tasks: Maximum number of tasks to return
|
|
141
|
+
include_completed: If True, include completed tasks
|
|
142
|
+
only_completed: If True, return only completed tasks
|
|
143
|
+
"""
|
|
144
|
+
_require_list(list_name, list_id)
|
|
145
|
+
|
|
146
|
+
# For compatibility with cli
|
|
147
|
+
if list_id is None:
|
|
148
|
+
list_id = get_list_id_by_name(list_name)
|
|
149
|
+
|
|
150
|
+
if only_completed:
|
|
151
|
+
endpoint = (
|
|
152
|
+
f"{BASE_URL}/{list_id}/tasks?$filter=status eq 'completed'&$top={num_tasks}"
|
|
153
|
+
)
|
|
154
|
+
elif include_completed:
|
|
155
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks?$top={num_tasks}"
|
|
156
|
+
else:
|
|
157
|
+
endpoint = (
|
|
158
|
+
f"{BASE_URL}/{list_id}/tasks?$filter=status ne 'completed'&$top={num_tasks}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
session = get_oauth_session()
|
|
162
|
+
response = session.get(endpoint)
|
|
163
|
+
response_value = parse_response(response)
|
|
164
|
+
return [Task(x) for x in response_value]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def create_task(
|
|
168
|
+
task_name: str,
|
|
169
|
+
list_name: str | None = None,
|
|
170
|
+
list_id: str | None = None,
|
|
171
|
+
reminder_datetime: datetime | None = None,
|
|
172
|
+
due_datetime: datetime | None = None,
|
|
173
|
+
important: bool = False,
|
|
174
|
+
recurrence: dict | None = None,
|
|
175
|
+
):
|
|
176
|
+
_require_list(list_name, list_id)
|
|
177
|
+
|
|
178
|
+
# For compatibility with cli
|
|
179
|
+
if list_id is None:
|
|
180
|
+
list_id = get_list_id_by_name(list_name)
|
|
181
|
+
|
|
182
|
+
# The Graph API requires dueDateTime when recurrence is set
|
|
183
|
+
if due_datetime is None and recurrence is not None:
|
|
184
|
+
due_datetime = datetime.now()
|
|
185
|
+
|
|
186
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks"
|
|
187
|
+
request_body = {
|
|
188
|
+
"title": task_name,
|
|
189
|
+
"reminderDateTime": datetime_to_api_timestamp(reminder_datetime),
|
|
190
|
+
"dueDateTime": datetime_to_api_timestamp(due_datetime),
|
|
191
|
+
"importance": TaskImportance.HIGH if important else TaskImportance.NORMAL,
|
|
192
|
+
"recurrence": recurrence,
|
|
193
|
+
}
|
|
194
|
+
session = get_oauth_session()
|
|
195
|
+
response = session.post(endpoint, json=request_body)
|
|
196
|
+
if response.ok:
|
|
197
|
+
return json.loads(response.content.decode())["id"]
|
|
198
|
+
else:
|
|
199
|
+
response.raise_for_status()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def complete_task(
|
|
203
|
+
list_name: str = None,
|
|
204
|
+
task_name: Union[str, int] = None,
|
|
205
|
+
list_id: str = None,
|
|
206
|
+
task_id: str = None,
|
|
207
|
+
):
|
|
208
|
+
"""Mark a task as completed. Returns (task_id, task_title)."""
|
|
209
|
+
_require_list(list_name, list_id)
|
|
210
|
+
_require_task(task_name, task_id)
|
|
211
|
+
|
|
212
|
+
# For compatibility with cli
|
|
213
|
+
if list_id is None:
|
|
214
|
+
list_id = get_list_id_by_name(list_name)
|
|
215
|
+
if task_id is None:
|
|
216
|
+
task_id = get_task_id_by_name(list_name, task_name)
|
|
217
|
+
|
|
218
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
|
|
219
|
+
request_body = {
|
|
220
|
+
"status": TaskStatus.COMPLETED,
|
|
221
|
+
"completedDateTime": datetime_to_api_timestamp(datetime.now()),
|
|
222
|
+
}
|
|
223
|
+
session = get_oauth_session()
|
|
224
|
+
response = session.patch(endpoint, json=request_body)
|
|
225
|
+
if response.ok:
|
|
226
|
+
data = json.loads(response.content.decode())
|
|
227
|
+
return task_id, data.get("title", "")
|
|
228
|
+
response.raise_for_status()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def uncomplete_task(
|
|
232
|
+
list_name: str = None,
|
|
233
|
+
task_name: Union[str, int] = None,
|
|
234
|
+
list_id: str = None,
|
|
235
|
+
task_id: str = None,
|
|
236
|
+
):
|
|
237
|
+
"""Mark a completed task as not completed. Returns (task_id, task_title)."""
|
|
238
|
+
_require_list(list_name, list_id)
|
|
239
|
+
_require_task(task_name, task_id)
|
|
240
|
+
|
|
241
|
+
if list_id is None:
|
|
242
|
+
list_id = get_list_id_by_name(list_name)
|
|
243
|
+
if task_id is None:
|
|
244
|
+
task_id = get_task_id_by_name(list_name, task_name)
|
|
245
|
+
|
|
246
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
|
|
247
|
+
request_body = {
|
|
248
|
+
"status": TaskStatus.NOT_STARTED,
|
|
249
|
+
"completedDateTime": None,
|
|
250
|
+
}
|
|
251
|
+
session = get_oauth_session()
|
|
252
|
+
response = session.patch(endpoint, json=request_body)
|
|
253
|
+
if response.ok:
|
|
254
|
+
data = json.loads(response.content.decode())
|
|
255
|
+
return task_id, data.get("title", "")
|
|
256
|
+
response.raise_for_status()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def complete_tasks(list_id, task_ids=None):
|
|
260
|
+
if task_ids is None:
|
|
261
|
+
task_ids = []
|
|
262
|
+
body = {"requests": []}
|
|
263
|
+
for task_id in task_ids:
|
|
264
|
+
body["requests"].append(
|
|
265
|
+
{
|
|
266
|
+
"id": task_id,
|
|
267
|
+
"method": "PATCH",
|
|
268
|
+
"url": f"{BASE_RELATE_URL}/{list_id}/tasks/{task_id}",
|
|
269
|
+
"headers": {"Content-Type": "application/json"},
|
|
270
|
+
"body": {
|
|
271
|
+
"status": TaskStatus.COMPLETED,
|
|
272
|
+
"completedDateTime": datetime_to_api_timestamp(datetime.now()),
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
session = get_oauth_session()
|
|
277
|
+
response = session.post(BATCH_URL, json=body)
|
|
278
|
+
return True if response.ok else response.raise_for_status()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def remove_task(
|
|
282
|
+
list_name: str = None,
|
|
283
|
+
task_name: Union[str, int] = None,
|
|
284
|
+
list_id: str = None,
|
|
285
|
+
task_id: str = None,
|
|
286
|
+
):
|
|
287
|
+
"""Delete a task. Returns (task_id, task_title)."""
|
|
288
|
+
_require_list(list_name, list_id)
|
|
289
|
+
_require_task(task_name, task_id)
|
|
290
|
+
|
|
291
|
+
if list_id is None:
|
|
292
|
+
list_id = get_list_id_by_name(list_name)
|
|
293
|
+
if task_id is None:
|
|
294
|
+
task_id = get_task_id_by_name(list_name, task_name)
|
|
295
|
+
|
|
296
|
+
# Fetch task title before deletion
|
|
297
|
+
task = get_task(list_id=list_id, task_id=task_id)
|
|
298
|
+
task_title = task.title
|
|
299
|
+
|
|
300
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
|
|
301
|
+
session = get_oauth_session()
|
|
302
|
+
response = session.delete(endpoint)
|
|
303
|
+
if response.ok:
|
|
304
|
+
return task_id, task_title
|
|
305
|
+
response.raise_for_status()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def update_task(
|
|
309
|
+
list_name: str = None,
|
|
310
|
+
task_name: Union[str, int] = None,
|
|
311
|
+
list_id: str = None,
|
|
312
|
+
task_id: str = None,
|
|
313
|
+
title: str | None = None,
|
|
314
|
+
due_datetime: datetime | None = None,
|
|
315
|
+
reminder_datetime: datetime | None = None,
|
|
316
|
+
important: bool | None = None,
|
|
317
|
+
recurrence: dict | None = None,
|
|
318
|
+
clear_due: bool = False,
|
|
319
|
+
clear_reminder: bool = False,
|
|
320
|
+
clear_recurrence: bool = False,
|
|
321
|
+
):
|
|
322
|
+
"""Update a task. Returns (task_id, task_title)."""
|
|
323
|
+
_require_list(list_name, list_id)
|
|
324
|
+
_require_task(task_name, task_id)
|
|
325
|
+
|
|
326
|
+
if list_id is None:
|
|
327
|
+
list_id = get_list_id_by_name(list_name)
|
|
328
|
+
if task_id is None:
|
|
329
|
+
task_id = get_task_id_by_name(list_name, task_name)
|
|
330
|
+
|
|
331
|
+
request_body = {}
|
|
332
|
+
if title is not None:
|
|
333
|
+
request_body["title"] = title
|
|
334
|
+
if clear_due:
|
|
335
|
+
request_body["dueDateTime"] = None
|
|
336
|
+
elif due_datetime is not None:
|
|
337
|
+
request_body["dueDateTime"] = datetime_to_api_timestamp(due_datetime)
|
|
338
|
+
if clear_reminder:
|
|
339
|
+
request_body["reminderDateTime"] = None
|
|
340
|
+
request_body["isReminderOn"] = False
|
|
341
|
+
elif reminder_datetime is not None:
|
|
342
|
+
request_body["reminderDateTime"] = datetime_to_api_timestamp(reminder_datetime)
|
|
343
|
+
if important is not None:
|
|
344
|
+
request_body["importance"] = (
|
|
345
|
+
TaskImportance.HIGH if important else TaskImportance.NORMAL
|
|
346
|
+
)
|
|
347
|
+
if clear_recurrence:
|
|
348
|
+
request_body["recurrence"] = None
|
|
349
|
+
elif recurrence is not None:
|
|
350
|
+
request_body["recurrence"] = recurrence
|
|
351
|
+
|
|
352
|
+
if not request_body:
|
|
353
|
+
raise ValueError("No fields to update")
|
|
354
|
+
|
|
355
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
|
|
356
|
+
session = get_oauth_session()
|
|
357
|
+
response = session.patch(endpoint, json=request_body)
|
|
358
|
+
if response.ok:
|
|
359
|
+
data = json.loads(response.content.decode())
|
|
360
|
+
return task_id, data.get("title", "")
|
|
361
|
+
response.raise_for_status()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def get_list_id_by_name(list_name: str) -> str:
|
|
365
|
+
"""Get list ID by exact name match."""
|
|
366
|
+
escaped_name = _escape_odata_string(list_name)
|
|
367
|
+
endpoint = f"{BASE_URL}?$filter=displayName eq '{escaped_name}'"
|
|
368
|
+
session = get_oauth_session()
|
|
369
|
+
response = session.get(endpoint)
|
|
370
|
+
response_value = parse_response(response)
|
|
371
|
+
try:
|
|
372
|
+
return response_value[0]["id"]
|
|
373
|
+
except IndexError:
|
|
374
|
+
raise ListNotFound(list_name)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _escape_odata_string(value: str) -> str:
|
|
378
|
+
"""Escape a string for use in an OData $filter expression embedded in a URL.
|
|
379
|
+
|
|
380
|
+
Single quotes are doubled per OData spec.
|
|
381
|
+
|
|
382
|
+
Certain characters have special meaning in URLs and are not
|
|
383
|
+
percent-encoded by the HTTP client when they appear in a
|
|
384
|
+
raw query string:
|
|
385
|
+
'#' — fragment identifier (truncates the URL)
|
|
386
|
+
'&' — query-parameter separator
|
|
387
|
+
'+' — interpreted as space in query strings
|
|
388
|
+
|
|
389
|
+
These must be double-percent-encoded so that the first decode
|
|
390
|
+
pass (HTTP/transport layer) yields the standard percent-encoded
|
|
391
|
+
form, and the second pass (OData parser) yields the literal
|
|
392
|
+
character. E.g. '#' → '%2523' → '%23' → '#'.
|
|
393
|
+
|
|
394
|
+
Reference: https://learn.microsoft.com/en-us/answers/questions/
|
|
395
|
+
432875/how-do-you-escape-the-octothorpe-number-pound-hashtag-
|
|
396
|
+
symbol-in-a-graph-api-odata-search-string
|
|
397
|
+
"""
|
|
398
|
+
return (
|
|
399
|
+
value.replace("'", "''")
|
|
400
|
+
.replace("#", "%2523")
|
|
401
|
+
.replace("&", "%2526")
|
|
402
|
+
.replace("+", "%252B")
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def get_task_id_by_name(list_name: str, task_name: str):
|
|
407
|
+
if isinstance(task_name, str):
|
|
408
|
+
try:
|
|
409
|
+
list_id = get_list_id_by_name(list_name)
|
|
410
|
+
escaped_name = _escape_odata_string(task_name)
|
|
411
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks?$filter=title eq '{escaped_name}'"
|
|
412
|
+
session = get_oauth_session()
|
|
413
|
+
response = session.get(endpoint)
|
|
414
|
+
response_value = parse_response(response)
|
|
415
|
+
return [Task(x) for x in response_value][0].id
|
|
416
|
+
except IndexError:
|
|
417
|
+
raise TaskNotFoundByName(task_name, list_name)
|
|
418
|
+
elif isinstance(task_name, int):
|
|
419
|
+
tasks = get_tasks(list_name=list_name)
|
|
420
|
+
try:
|
|
421
|
+
return tasks[task_name].id
|
|
422
|
+
except IndexError:
|
|
423
|
+
raise TaskNotFoundByIndex(task_name, list_name)
|
|
424
|
+
else:
|
|
425
|
+
raise TypeError(f"task_name must be str or int, got {type(task_name).__name__}")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def get_task(
|
|
429
|
+
list_name: str = None,
|
|
430
|
+
task_name: Union[str, int] = None,
|
|
431
|
+
list_id: str = None,
|
|
432
|
+
task_id: str = None,
|
|
433
|
+
):
|
|
434
|
+
"""Fetch a single task with all details."""
|
|
435
|
+
_require_list(list_name, list_id)
|
|
436
|
+
_require_task(task_name, task_id)
|
|
437
|
+
|
|
438
|
+
if list_id is None:
|
|
439
|
+
list_id = get_list_id_by_name(list_name)
|
|
440
|
+
if task_id is None:
|
|
441
|
+
task_id = get_task_id_by_name(list_name, task_name)
|
|
442
|
+
|
|
443
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
|
|
444
|
+
session = get_oauth_session()
|
|
445
|
+
response = session.get(endpoint)
|
|
446
|
+
if response.ok:
|
|
447
|
+
return Task(json.loads(response.content.decode()))
|
|
448
|
+
response.raise_for_status()
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def get_checklist_items(
|
|
452
|
+
list_name: str = None,
|
|
453
|
+
task_name: Union[str, int] = None,
|
|
454
|
+
list_id: str = None,
|
|
455
|
+
task_id: str = None,
|
|
456
|
+
):
|
|
457
|
+
_require_list(list_name, list_id)
|
|
458
|
+
_require_task(task_name, task_id)
|
|
459
|
+
|
|
460
|
+
if list_id is None:
|
|
461
|
+
list_id = get_list_id_by_name(list_name)
|
|
462
|
+
if task_id is None:
|
|
463
|
+
task_id = get_task_id_by_name(list_name, task_name)
|
|
464
|
+
|
|
465
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}/checklistItems"
|
|
466
|
+
session = get_oauth_session()
|
|
467
|
+
response = session.get(endpoint)
|
|
468
|
+
response_value = parse_response(response)
|
|
469
|
+
return [ChecklistItem(x) for x in response_value]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
BATCH_MAX_REQUESTS = 20
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def get_checklist_items_batch(list_id: str, task_ids: list[str]):
|
|
476
|
+
"""Fetch checklist items for multiple tasks using $batch API.
|
|
477
|
+
|
|
478
|
+
Returns dict mapping task_id -> list[ChecklistItem].
|
|
479
|
+
"""
|
|
480
|
+
if not task_ids:
|
|
481
|
+
return {}
|
|
482
|
+
|
|
483
|
+
result = {}
|
|
484
|
+
session = get_oauth_session()
|
|
485
|
+
|
|
486
|
+
# Chunk into groups of BATCH_MAX_REQUESTS
|
|
487
|
+
for i in range(0, len(task_ids), BATCH_MAX_REQUESTS):
|
|
488
|
+
chunk = task_ids[i : i + BATCH_MAX_REQUESTS]
|
|
489
|
+
body = {
|
|
490
|
+
"requests": [
|
|
491
|
+
{
|
|
492
|
+
"id": task_id,
|
|
493
|
+
"method": "GET",
|
|
494
|
+
"url": f"{BASE_RELATE_URL}/{list_id}/tasks/{task_id}/checklistItems",
|
|
495
|
+
}
|
|
496
|
+
for task_id in chunk
|
|
497
|
+
]
|
|
498
|
+
}
|
|
499
|
+
response = session.post(BATCH_URL, json=body)
|
|
500
|
+
if not response.ok:
|
|
501
|
+
response.raise_for_status()
|
|
502
|
+
|
|
503
|
+
batch_response = json.loads(response.content.decode())
|
|
504
|
+
for resp in batch_response.get("responses", []):
|
|
505
|
+
tid = resp["id"]
|
|
506
|
+
if resp.get("status") == 200:
|
|
507
|
+
items = resp.get("body", {}).get("value", [])
|
|
508
|
+
result[tid] = [ChecklistItem(x) for x in items]
|
|
509
|
+
else:
|
|
510
|
+
result[tid] = []
|
|
511
|
+
|
|
512
|
+
return result
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def create_checklist_item(
|
|
516
|
+
step_name: str,
|
|
517
|
+
list_name: str = None,
|
|
518
|
+
task_name: Union[str, int] = None,
|
|
519
|
+
list_id: str = None,
|
|
520
|
+
task_id: str = None,
|
|
521
|
+
):
|
|
522
|
+
_require_list(list_name, list_id)
|
|
523
|
+
_require_task(task_name, task_id)
|
|
524
|
+
|
|
525
|
+
if list_id is None:
|
|
526
|
+
list_id = get_list_id_by_name(list_name)
|
|
527
|
+
if task_id is None:
|
|
528
|
+
task_id = get_task_id_by_name(list_name, task_name)
|
|
529
|
+
|
|
530
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}/checklistItems"
|
|
531
|
+
request_body = {"displayName": step_name}
|
|
532
|
+
session = get_oauth_session()
|
|
533
|
+
response = session.post(endpoint, json=request_body)
|
|
534
|
+
if response.ok:
|
|
535
|
+
data = json.loads(response.content.decode())
|
|
536
|
+
return data.get("id", ""), data.get("displayName", "")
|
|
537
|
+
response.raise_for_status()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def complete_checklist_item(
|
|
541
|
+
list_name: str = None,
|
|
542
|
+
task_name: Union[str, int] = None,
|
|
543
|
+
step_name: Union[str, int] = None,
|
|
544
|
+
list_id: str = None,
|
|
545
|
+
task_id: str = None,
|
|
546
|
+
step_id: str = None,
|
|
547
|
+
):
|
|
548
|
+
_require_list(list_name, list_id)
|
|
549
|
+
_require_task(task_name, task_id)
|
|
550
|
+
if step_id is None:
|
|
551
|
+
_require_step(step_name)
|
|
552
|
+
|
|
553
|
+
if list_id is None:
|
|
554
|
+
list_id = get_list_id_by_name(list_name)
|
|
555
|
+
if task_id is None:
|
|
556
|
+
task_id = get_task_id_by_name(list_name, task_name)
|
|
557
|
+
if step_id is None:
|
|
558
|
+
step_id = get_step_id(
|
|
559
|
+
list_name, task_name, step_name, list_id=list_id, task_id=task_id
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}/checklistItems/{step_id}"
|
|
563
|
+
request_body = {"isChecked": True}
|
|
564
|
+
session = get_oauth_session()
|
|
565
|
+
response = session.patch(endpoint, json=request_body)
|
|
566
|
+
if response.ok:
|
|
567
|
+
data = json.loads(response.content.decode())
|
|
568
|
+
return step_id, data.get("displayName", "")
|
|
569
|
+
response.raise_for_status()
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def uncomplete_checklist_item(
|
|
573
|
+
list_name: str = None,
|
|
574
|
+
task_name: Union[str, int] = None,
|
|
575
|
+
step_name: Union[str, int] = None,
|
|
576
|
+
list_id: str = None,
|
|
577
|
+
task_id: str = None,
|
|
578
|
+
step_id: str = None,
|
|
579
|
+
):
|
|
580
|
+
"""Mark a checked step as unchecked."""
|
|
581
|
+
_require_list(list_name, list_id)
|
|
582
|
+
_require_task(task_name, task_id)
|
|
583
|
+
if step_id is None:
|
|
584
|
+
_require_step(step_name)
|
|
585
|
+
|
|
586
|
+
if list_id is None:
|
|
587
|
+
list_id = get_list_id_by_name(list_name)
|
|
588
|
+
if task_id is None:
|
|
589
|
+
task_id = get_task_id_by_name(list_name, task_name)
|
|
590
|
+
if step_id is None:
|
|
591
|
+
step_id = get_step_id(
|
|
592
|
+
list_name, task_name, step_name, list_id=list_id, task_id=task_id
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}/checklistItems/{step_id}"
|
|
596
|
+
request_body = {"isChecked": False}
|
|
597
|
+
session = get_oauth_session()
|
|
598
|
+
response = session.patch(endpoint, json=request_body)
|
|
599
|
+
if response.ok:
|
|
600
|
+
data = json.loads(response.content.decode())
|
|
601
|
+
return step_id, data.get("displayName", "")
|
|
602
|
+
response.raise_for_status()
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def delete_checklist_item(
|
|
606
|
+
list_name: str = None,
|
|
607
|
+
task_name: Union[str, int] = None,
|
|
608
|
+
step_name: Union[str, int] = None,
|
|
609
|
+
list_id: str = None,
|
|
610
|
+
task_id: str = None,
|
|
611
|
+
step_id: str = None,
|
|
612
|
+
):
|
|
613
|
+
_require_list(list_name, list_id)
|
|
614
|
+
_require_task(task_name, task_id)
|
|
615
|
+
if step_id is None:
|
|
616
|
+
_require_step(step_name)
|
|
617
|
+
|
|
618
|
+
if list_id is None:
|
|
619
|
+
list_id = get_list_id_by_name(list_name)
|
|
620
|
+
if task_id is None:
|
|
621
|
+
task_id = get_task_id_by_name(list_name, task_name)
|
|
622
|
+
if step_id is None:
|
|
623
|
+
step_id = get_step_id(
|
|
624
|
+
list_name, task_name, step_name, list_id=list_id, task_id=task_id
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}/checklistItems/{step_id}"
|
|
628
|
+
session = get_oauth_session()
|
|
629
|
+
response = session.delete(endpoint)
|
|
630
|
+
if response.ok:
|
|
631
|
+
return step_id
|
|
632
|
+
response.raise_for_status()
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def get_step_id(
|
|
636
|
+
list_name: str,
|
|
637
|
+
task_name: Union[str, int],
|
|
638
|
+
step_name: Union[str, int],
|
|
639
|
+
list_id: str = None,
|
|
640
|
+
task_id: str = None,
|
|
641
|
+
):
|
|
642
|
+
if list_id is None:
|
|
643
|
+
list_id = get_list_id_by_name(list_name)
|
|
644
|
+
if task_id is None:
|
|
645
|
+
task_id = get_task_id_by_name(list_name, task_name)
|
|
646
|
+
|
|
647
|
+
items = get_checklist_items(list_id=list_id, task_id=task_id)
|
|
648
|
+
|
|
649
|
+
if isinstance(step_name, int):
|
|
650
|
+
try:
|
|
651
|
+
return items[step_name].id
|
|
652
|
+
except IndexError:
|
|
653
|
+
raise StepNotFoundByIndex(step_name, task_name)
|
|
654
|
+
elif isinstance(step_name, str):
|
|
655
|
+
for item in items:
|
|
656
|
+
if item.display_name == step_name:
|
|
657
|
+
return item.id
|
|
658
|
+
raise StepNotFoundByName(step_name, task_name)
|
|
659
|
+
else:
|
|
660
|
+
raise TypeError(f"step_name must be str or int, got {type(step_name).__name__}")
|
|
File without changes
|