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,59 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _parse_datetime(dt_str):
|
|
5
|
+
"""Parse datetime string from checklist items API.
|
|
6
|
+
|
|
7
|
+
The checklistItems endpoint returns datetimes without fractional seconds
|
|
8
|
+
(e.g. "2026-02-04T19:08:45Z"), unlike tasks which include 7 digits
|
|
9
|
+
(e.g. "2024-01-25T10:00:00.0000000Z").
|
|
10
|
+
"""
|
|
11
|
+
if dt_str is None:
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
if isinstance(dt_str, dict):
|
|
15
|
+
dt_str = dt_str["dateTime"]
|
|
16
|
+
|
|
17
|
+
# Strip trailing Z
|
|
18
|
+
dt_str = dt_str.rstrip("Z")
|
|
19
|
+
|
|
20
|
+
# Truncate fractional seconds to 6 digits (Python's %f limit)
|
|
21
|
+
if "." in dt_str:
|
|
22
|
+
base, frac = dt_str.rsplit(".", 1)
|
|
23
|
+
dt_str = f"{base}.{frac[:6]}"
|
|
24
|
+
|
|
25
|
+
for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
|
|
26
|
+
try:
|
|
27
|
+
dt = datetime.strptime(dt_str, fmt)
|
|
28
|
+
return dt.replace(tzinfo=timezone.utc).astimezone(tz=None)
|
|
29
|
+
except ValueError:
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ChecklistItem:
|
|
36
|
+
def __init__(self, query_result):
|
|
37
|
+
self.id: str = query_result["id"]
|
|
38
|
+
self.display_name: str = query_result["displayName"]
|
|
39
|
+
self.is_checked: bool = bool(query_result["isChecked"])
|
|
40
|
+
self.created_datetime = _parse_datetime(query_result["createdDateTime"])
|
|
41
|
+
|
|
42
|
+
if "checkedDateTime" in query_result and query_result["checkedDateTime"]:
|
|
43
|
+
self.checked_datetime = _parse_datetime(query_result["checkedDateTime"])
|
|
44
|
+
else:
|
|
45
|
+
self.checked_datetime = None
|
|
46
|
+
|
|
47
|
+
def to_dict(self):
|
|
48
|
+
"""Convert checklist item to dictionary for JSON serialization."""
|
|
49
|
+
return {
|
|
50
|
+
"id": self.id,
|
|
51
|
+
"display_name": self.display_name,
|
|
52
|
+
"is_checked": self.is_checked,
|
|
53
|
+
"created_datetime": (
|
|
54
|
+
self.created_datetime.isoformat() if self.created_datetime else None
|
|
55
|
+
),
|
|
56
|
+
"checked_datetime": (
|
|
57
|
+
self.checked_datetime.isoformat() if self.checked_datetime else None
|
|
58
|
+
),
|
|
59
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TodoList:
|
|
5
|
+
class WellKnownListName(Enum):
|
|
6
|
+
none = "none"
|
|
7
|
+
DefaultList = "defaultList"
|
|
8
|
+
FlaggedEmails = "flaggedEmails"
|
|
9
|
+
|
|
10
|
+
def __init__(self, query_result_list):
|
|
11
|
+
self.id: str = query_result_list["id"]
|
|
12
|
+
self.display_name: str = query_result_list["displayName"]
|
|
13
|
+
self.is_owner = bool(query_result_list["isOwner"])
|
|
14
|
+
self.is_shared = bool(query_result_list["isShared"])
|
|
15
|
+
self.well_known_list_name = TodoList.WellKnownListName(
|
|
16
|
+
query_result_list["wellknownListName"]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def to_dict(self):
|
|
20
|
+
"""Convert list to dictionary for JSON serialization."""
|
|
21
|
+
return {
|
|
22
|
+
"id": self.id,
|
|
23
|
+
"display_name": self.display_name,
|
|
24
|
+
"is_owner": self.is_owner,
|
|
25
|
+
"is_shared": self.is_shared,
|
|
26
|
+
"well_known_list_name": self.well_known_list_name.value,
|
|
27
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from todocli.utils.datetime_util import api_timestamp_to_datetime
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TaskStatus(str, Enum):
|
|
6
|
+
COMPLETED = "completed"
|
|
7
|
+
NOT_STARTED = "notStarted"
|
|
8
|
+
IN_PROGRESS = "inProgress"
|
|
9
|
+
WAITING_ON_OTHERS = "waitingOnOthers"
|
|
10
|
+
DEFERRED = "deferred"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TaskImportance(str, Enum):
|
|
14
|
+
LOW = "low"
|
|
15
|
+
NORMAL = "normal"
|
|
16
|
+
HIGH = "high"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RecurrencePatternType(str, Enum):
|
|
20
|
+
DAILY = "daily"
|
|
21
|
+
WEEKLY = "weekly"
|
|
22
|
+
ABSOLUTE_MONTHLY = "absoluteMonthly"
|
|
23
|
+
RELATIVE_MONTHLY = "relativeMonthly"
|
|
24
|
+
ABSOLUTE_YEARLY = "absoluteYearly"
|
|
25
|
+
RELATIVE_YEARLY = "relativeYearly"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DayOfWeek(str, Enum):
|
|
29
|
+
MONDAY = "monday"
|
|
30
|
+
TUESDAY = "tuesday"
|
|
31
|
+
WEDNESDAY = "wednesday"
|
|
32
|
+
THURSDAY = "thursday"
|
|
33
|
+
FRIDAY = "friday"
|
|
34
|
+
SATURDAY = "saturday"
|
|
35
|
+
SUNDAY = "sunday"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Task:
|
|
39
|
+
def __init__(self, query_result):
|
|
40
|
+
self.title = query_result["title"]
|
|
41
|
+
self.id = query_result["id"]
|
|
42
|
+
self.importance = TaskImportance(query_result["importance"])
|
|
43
|
+
self.status = TaskStatus(query_result["status"])
|
|
44
|
+
self.created_datetime = api_timestamp_to_datetime(
|
|
45
|
+
query_result["createdDateTime"]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if "completedDateTime" in query_result:
|
|
49
|
+
self.completed_datetime = api_timestamp_to_datetime(
|
|
50
|
+
query_result["completedDateTime"]
|
|
51
|
+
)
|
|
52
|
+
else:
|
|
53
|
+
self.completed_datetime = None
|
|
54
|
+
|
|
55
|
+
self.is_reminder_on: bool = bool(query_result["isReminderOn"])
|
|
56
|
+
|
|
57
|
+
if "dueDateTime" in query_result:
|
|
58
|
+
self.due_datetime = api_timestamp_to_datetime(query_result["dueDateTime"])
|
|
59
|
+
else:
|
|
60
|
+
self.due_datetime = None
|
|
61
|
+
|
|
62
|
+
if "reminderDateTime" in query_result:
|
|
63
|
+
self.reminder_datetime = api_timestamp_to_datetime(
|
|
64
|
+
query_result["reminderDateTime"]
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
self.reminder_datetime = None
|
|
68
|
+
|
|
69
|
+
self.last_modified_datetime = api_timestamp_to_datetime(
|
|
70
|
+
query_result["lastModifiedDateTime"]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if "bodyLastModifiedDateTime" in query_result:
|
|
74
|
+
self.body_last_modified_datetime = api_timestamp_to_datetime(
|
|
75
|
+
query_result["bodyLastModifiedDateTime"]
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
self.body_last_modified_datetime = None
|
|
79
|
+
|
|
80
|
+
def to_dict(self):
|
|
81
|
+
"""Convert task to dictionary for JSON serialization."""
|
|
82
|
+
return {
|
|
83
|
+
"id": self.id,
|
|
84
|
+
"title": self.title,
|
|
85
|
+
"status": self.status.value,
|
|
86
|
+
"importance": self.importance.value,
|
|
87
|
+
"is_reminder_on": self.is_reminder_on,
|
|
88
|
+
"created_datetime": (
|
|
89
|
+
self.created_datetime.isoformat() if self.created_datetime else None
|
|
90
|
+
),
|
|
91
|
+
"due_datetime": (
|
|
92
|
+
self.due_datetime.isoformat() if self.due_datetime else None
|
|
93
|
+
),
|
|
94
|
+
"reminder_datetime": (
|
|
95
|
+
self.reminder_datetime.isoformat() if self.reminder_datetime else None
|
|
96
|
+
),
|
|
97
|
+
"completed_datetime": (
|
|
98
|
+
self.completed_datetime.isoformat() if self.completed_datetime else None
|
|
99
|
+
),
|
|
100
|
+
"last_modified_datetime": (
|
|
101
|
+
self.last_modified_datetime.isoformat()
|
|
102
|
+
if self.last_modified_datetime
|
|
103
|
+
else None
|
|
104
|
+
),
|
|
105
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TimeExpressionNotRecognized(Exception):
|
|
7
|
+
def __init__(self, time_str):
|
|
8
|
+
self.message = (
|
|
9
|
+
f"Time expression could not be parsed: {time_str}\n"
|
|
10
|
+
f"Supported formats: 1h, 30m, 9am, 5:30pm, 17:00, tomorrow, monday, mon, "
|
|
11
|
+
f"DD.MM.YYYY, YYYY-MM-DD, MM/DD/YYYY"
|
|
12
|
+
)
|
|
13
|
+
super(TimeExpressionNotRecognized, self).__init__(self.message)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ErrorParsingTime(Exception):
|
|
17
|
+
def __init__(self, message):
|
|
18
|
+
self.message = message
|
|
19
|
+
super(ErrorParsingTime, self).__init__(message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_hour_minute(input_str):
|
|
23
|
+
split_str = input_str.split(":")
|
|
24
|
+
hour = int(split_str[0])
|
|
25
|
+
minute = int(split_str[1])
|
|
26
|
+
return hour, minute
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_day_month_DD_MM(input_str):
|
|
30
|
+
split_str = input_str.split(".")
|
|
31
|
+
day = int(split_str[0])
|
|
32
|
+
month = int(split_str[1])
|
|
33
|
+
return day, month
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_day_month_MM_DD(input_str):
|
|
37
|
+
split_str = input_str.split("/")
|
|
38
|
+
month = int(split_str[0])
|
|
39
|
+
day = int(split_str[1])
|
|
40
|
+
return day, month
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_day_month_DD_MM_YYorYYYY(input_str):
|
|
44
|
+
split_str = input_str.split(".")
|
|
45
|
+
day = int(split_str[0])
|
|
46
|
+
month = int(split_str[1])
|
|
47
|
+
year = split_str[2]
|
|
48
|
+
if len(year) == 2:
|
|
49
|
+
year = int("20" + year)
|
|
50
|
+
else:
|
|
51
|
+
year = int(year)
|
|
52
|
+
return day, month, year
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def add_day_if_past(dt: datetime) -> datetime:
|
|
56
|
+
"""This function will add a day to the datetime object 'dt' when 'dt' is in the past"""
|
|
57
|
+
dt_now = datetime.now()
|
|
58
|
+
if dt < dt_now:
|
|
59
|
+
return dt + timedelta(days=1)
|
|
60
|
+
else:
|
|
61
|
+
return dt
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_datetime(datetime_str: str):
|
|
65
|
+
try:
|
|
66
|
+
if match := re.match(
|
|
67
|
+
r"(?:(\d+)/(\d+)/)?(\d+d)?(\d+h)?(\d+m)?(\d+s)?$",
|
|
68
|
+
datetime_str,
|
|
69
|
+
re.IGNORECASE,
|
|
70
|
+
):
|
|
71
|
+
"""e.g. 1h / 12h"""
|
|
72
|
+
multiplier = 1
|
|
73
|
+
if match.group(1):
|
|
74
|
+
multiplier = int(match.group(2)) - int(match.group(1))
|
|
75
|
+
return (
|
|
76
|
+
datetime.now()
|
|
77
|
+
+ timedelta(
|
|
78
|
+
days=0 if match.group(3) is None else int(match.group(3)[:-1]),
|
|
79
|
+
hours=0 if match.group(4) is None else int(match.group(4)[:-1]),
|
|
80
|
+
minutes=0 if match.group(5) is None else int(match.group(5)[:-1]),
|
|
81
|
+
seconds=0 if match.group(6) is None else int(match.group(6)[:-1]),
|
|
82
|
+
)
|
|
83
|
+
* multiplier
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if datetime_str == "morning":
|
|
87
|
+
dt = datetime.now()
|
|
88
|
+
return add_day_if_past(
|
|
89
|
+
dt.replace(hour=7, minute=0, second=0, microsecond=0)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if datetime_str == "tomorrow":
|
|
93
|
+
dt = datetime.now()
|
|
94
|
+
return dt.replace(hour=7, minute=0, second=0, microsecond=0) + timedelta(
|
|
95
|
+
days=1
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if datetime_str == "evening":
|
|
99
|
+
dt = datetime.now()
|
|
100
|
+
return add_day_if_past(
|
|
101
|
+
dt.replace(hour=18, minute=0, second=0, microsecond=0)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Day names (monday, mon, tuesday, tue, etc.)
|
|
105
|
+
day_names = {
|
|
106
|
+
"monday": 0, "mon": 0,
|
|
107
|
+
"tuesday": 1, "tue": 1,
|
|
108
|
+
"wednesday": 2, "wed": 2,
|
|
109
|
+
"thursday": 3, "thu": 3,
|
|
110
|
+
"friday": 4, "fri": 4,
|
|
111
|
+
"saturday": 5, "sat": 5,
|
|
112
|
+
"sunday": 6, "sun": 6,
|
|
113
|
+
}
|
|
114
|
+
if datetime_str.lower() in day_names:
|
|
115
|
+
target_weekday = day_names[datetime_str.lower()]
|
|
116
|
+
dt = datetime.now()
|
|
117
|
+
current_weekday = dt.weekday()
|
|
118
|
+
days_ahead = target_weekday - current_weekday
|
|
119
|
+
if days_ahead <= 0: # Target day already happened this week
|
|
120
|
+
days_ahead += 7
|
|
121
|
+
return (dt + timedelta(days=days_ahead)).replace(
|
|
122
|
+
hour=7, minute=0, second=0, microsecond=0
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Time without space: 9am, 5pm, 5:30pm, 10:30am
|
|
126
|
+
if match := re.match(r"^(\d{1,2})(?::(\d{2}))?(am|pm)$", datetime_str, re.IGNORECASE):
|
|
127
|
+
hour = int(match.group(1))
|
|
128
|
+
minute = int(match.group(2)) if match.group(2) else 0
|
|
129
|
+
is_pm = match.group(3).lower() == "pm"
|
|
130
|
+
|
|
131
|
+
if is_pm and hour != 12:
|
|
132
|
+
hour += 12
|
|
133
|
+
elif not is_pm and hour == 12:
|
|
134
|
+
hour = 0
|
|
135
|
+
|
|
136
|
+
return add_day_if_past(
|
|
137
|
+
datetime.now().replace(
|
|
138
|
+
hour=hour, minute=minute, second=0, microsecond=0
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if re.match(r"([0-9]{1,2}:[0-9]{2} (am|pm))", datetime_str, re.IGNORECASE):
|
|
143
|
+
"""e.g. 5:30 pm"""
|
|
144
|
+
split_str = datetime_str.split(" ")
|
|
145
|
+
hour, minute = parse_hour_minute(split_str[0])
|
|
146
|
+
|
|
147
|
+
if split_str[1].lower() == "am":
|
|
148
|
+
if hour == 12:
|
|
149
|
+
hour = 0
|
|
150
|
+
else:
|
|
151
|
+
if hour != 12:
|
|
152
|
+
hour = hour + 12
|
|
153
|
+
|
|
154
|
+
return add_day_if_past(
|
|
155
|
+
datetime.now().replace(
|
|
156
|
+
hour=hour, minute=minute, second=0, microsecond=0
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if re.match(r"([0-9]{1,2}:[0-9]{2})", datetime_str, re.IGNORECASE):
|
|
161
|
+
"""e.g. 17:00"""
|
|
162
|
+
hour, minute = parse_hour_minute(datetime_str)
|
|
163
|
+
return add_day_if_past(
|
|
164
|
+
datetime.now().replace(
|
|
165
|
+
hour=hour, minute=minute, second=0, microsecond=0
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if re.match(
|
|
170
|
+
r"([0-9]{1,2}\.[0-9]{1,2}\.(([0-9]{4})|([0-9]{2})))$",
|
|
171
|
+
datetime_str,
|
|
172
|
+
re.IGNORECASE,
|
|
173
|
+
):
|
|
174
|
+
"""e.g. 17.01.20 or 22.12.2020"""
|
|
175
|
+
day, month, year = parse_day_month_DD_MM_YYorYYYY(datetime_str)
|
|
176
|
+
return datetime.now().replace(
|
|
177
|
+
year=year,
|
|
178
|
+
day=day,
|
|
179
|
+
month=month,
|
|
180
|
+
hour=7,
|
|
181
|
+
minute=0,
|
|
182
|
+
second=0,
|
|
183
|
+
microsecond=0,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if re.match(r"\d{4}-\d{1,2}-\d{1,2}$", datetime_str):
|
|
187
|
+
"""e.g. 2026-02-11 or 2026-2-11 (ISO 8601)"""
|
|
188
|
+
parts = datetime_str.split("-")
|
|
189
|
+
year = int(parts[0])
|
|
190
|
+
month = int(parts[1])
|
|
191
|
+
day = int(parts[2])
|
|
192
|
+
return datetime.now().replace(
|
|
193
|
+
year=year,
|
|
194
|
+
day=day,
|
|
195
|
+
month=month,
|
|
196
|
+
hour=7,
|
|
197
|
+
minute=0,
|
|
198
|
+
second=0,
|
|
199
|
+
microsecond=0,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if re.match(
|
|
203
|
+
r"([0-9]{1,2}/[0-9]{1,2}/(([0-9]{4})|([0-9]{2})))$",
|
|
204
|
+
datetime_str,
|
|
205
|
+
):
|
|
206
|
+
"""e.g. 02/11/2026 or 02/11/26 (US date, MM/DD/YYYY)"""
|
|
207
|
+
parts = datetime_str.split("/")
|
|
208
|
+
month = int(parts[0])
|
|
209
|
+
day = int(parts[1])
|
|
210
|
+
year_str = parts[2]
|
|
211
|
+
year = int("20" + year_str) if len(year_str) == 2 else int(year_str)
|
|
212
|
+
return datetime.now().replace(
|
|
213
|
+
year=year,
|
|
214
|
+
day=day,
|
|
215
|
+
month=month,
|
|
216
|
+
hour=7,
|
|
217
|
+
minute=0,
|
|
218
|
+
second=0,
|
|
219
|
+
microsecond=0,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if re.match(
|
|
223
|
+
r"([0-9]{1,2}\.[0-9]{1,2}\. [0-9]{1,2}:[0-9]{2})",
|
|
224
|
+
datetime_str,
|
|
225
|
+
re.IGNORECASE,
|
|
226
|
+
):
|
|
227
|
+
"""e.g. 17.01. 17:00"""
|
|
228
|
+
split_str = datetime_str.split(" ")
|
|
229
|
+
day, month = parse_day_month_DD_MM(split_str[0])
|
|
230
|
+
hour, minute = parse_hour_minute(split_str[1])
|
|
231
|
+
return datetime.now().replace(
|
|
232
|
+
day=day, month=month, hour=hour, minute=minute, second=0, microsecond=0
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if re.match(
|
|
236
|
+
r"([0-9]{1,2}/[0-9]{1,2} [0-9]{1,2}:[0-9]{2} (am|pm))",
|
|
237
|
+
datetime_str,
|
|
238
|
+
re.IGNORECASE,
|
|
239
|
+
):
|
|
240
|
+
"""e.g. 01/17 5:00 pm"""
|
|
241
|
+
split_str = datetime_str.split(" ")
|
|
242
|
+
day, month = parse_day_month_MM_DD(split_str[0])
|
|
243
|
+
hour, minute = parse_hour_minute(split_str[1])
|
|
244
|
+
|
|
245
|
+
if split_str[2].lower() == "am":
|
|
246
|
+
if hour == 12:
|
|
247
|
+
hour = 0
|
|
248
|
+
else:
|
|
249
|
+
if hour != 12:
|
|
250
|
+
hour = hour + 12
|
|
251
|
+
|
|
252
|
+
return datetime.now().replace(
|
|
253
|
+
day=day, month=month, hour=hour, minute=minute, second=0, microsecond=0
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
except ValueError as e:
|
|
257
|
+
raise ErrorParsingTime(str(e))
|
|
258
|
+
|
|
259
|
+
raise TimeExpressionNotRecognized(datetime_str)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def format_date(dt: datetime, fmt: str = "eu") -> str:
|
|
263
|
+
if fmt == "iso":
|
|
264
|
+
return dt.strftime("%Y-%m-%d")
|
|
265
|
+
elif fmt == "us":
|
|
266
|
+
return dt.strftime("%m/%d/%Y")
|
|
267
|
+
else:
|
|
268
|
+
return dt.strftime("%d.%m.%Y")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def datetime_to_api_timestamp(dt: datetime | None):
|
|
272
|
+
if dt is None:
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
utc_dt = dt.astimezone(timezone.utc)
|
|
276
|
+
timestamp_str = utc_dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
277
|
+
|
|
278
|
+
api_dt = {"dateTime": timestamp_str, "timeZone": "UTC"}
|
|
279
|
+
return api_dt
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def api_timestamp_to_datetime(api_dt: Union[str, dict]):
|
|
283
|
+
"""Converts the datetime string returned by the API to python datetime object.
|
|
284
|
+
|
|
285
|
+
Handles various timestamp formats from Microsoft Graph API:
|
|
286
|
+
- With 7-digit microseconds: "2024-01-25T10:00:00.0000000Z"
|
|
287
|
+
- Without microseconds: "2024-01-25T10:00:00Z"
|
|
288
|
+
- Dict format: {"dateTime": "...", "timeZone": "UTC"}
|
|
289
|
+
"""
|
|
290
|
+
if api_dt is None:
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
# Extract the datetime string
|
|
294
|
+
if isinstance(api_dt, str):
|
|
295
|
+
dt_str = api_dt
|
|
296
|
+
elif isinstance(api_dt, dict):
|
|
297
|
+
dt_str = api_dt.get("dateTime", "")
|
|
298
|
+
else:
|
|
299
|
+
raise TypeError(f"Expected str or dict, got {type(api_dt).__name__}")
|
|
300
|
+
|
|
301
|
+
# Strip trailing Z
|
|
302
|
+
dt_str = dt_str.rstrip("Z")
|
|
303
|
+
|
|
304
|
+
# Truncate fractional seconds to 6 digits (Python's %f limit)
|
|
305
|
+
if "." in dt_str:
|
|
306
|
+
base, frac = dt_str.rsplit(".", 1)
|
|
307
|
+
dt_str = f"{base}.{frac[:6]}"
|
|
308
|
+
|
|
309
|
+
# Try parsing with and without fractional seconds
|
|
310
|
+
for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
|
|
311
|
+
try:
|
|
312
|
+
dt = datetime.strptime(dt_str, fmt)
|
|
313
|
+
return utc_to_local(dt)
|
|
314
|
+
except ValueError:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
raise ValueError(f"Unable to parse datetime: {api_dt}")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def utc_to_local(_dt):
|
|
321
|
+
return _dt.replace(tzinfo=timezone.utc).astimezone(tz=None)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import date
|
|
3
|
+
|
|
4
|
+
from todocli.models.todotask import RecurrencePatternType, DayOfWeek
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InvalidRecurrenceExpression(Exception):
|
|
8
|
+
def __init__(self, expr):
|
|
9
|
+
self.message = f"Invalid recurrence expression: '{expr}'"
|
|
10
|
+
super().__init__(self.message)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DAY_MAP = {
|
|
14
|
+
"mon": DayOfWeek.MONDAY,
|
|
15
|
+
"tue": DayOfWeek.TUESDAY,
|
|
16
|
+
"wed": DayOfWeek.WEDNESDAY,
|
|
17
|
+
"thu": DayOfWeek.THURSDAY,
|
|
18
|
+
"fri": DayOfWeek.FRIDAY,
|
|
19
|
+
"sat": DayOfWeek.SATURDAY,
|
|
20
|
+
"sun": DayOfWeek.SUNDAY,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
UNIT_MAP = {
|
|
24
|
+
"day": RecurrencePatternType.DAILY,
|
|
25
|
+
"days": RecurrencePatternType.DAILY,
|
|
26
|
+
"week": RecurrencePatternType.WEEKLY,
|
|
27
|
+
"weeks": RecurrencePatternType.WEEKLY,
|
|
28
|
+
"month": RecurrencePatternType.ABSOLUTE_MONTHLY,
|
|
29
|
+
"months": RecurrencePatternType.ABSOLUTE_MONTHLY,
|
|
30
|
+
"year": RecurrencePatternType.ABSOLUTE_YEARLY,
|
|
31
|
+
"years": RecurrencePatternType.ABSOLUTE_YEARLY,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
PRESET_MAP = {
|
|
35
|
+
"daily": (RecurrencePatternType.DAILY, 1),
|
|
36
|
+
"weekly": (RecurrencePatternType.WEEKLY, 1),
|
|
37
|
+
"monthly": (RecurrencePatternType.ABSOLUTE_MONTHLY, 1),
|
|
38
|
+
"yearly": (RecurrencePatternType.ABSOLUTE_YEARLY, 1),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
WEEKDAYS = [
|
|
42
|
+
DayOfWeek.MONDAY,
|
|
43
|
+
DayOfWeek.TUESDAY,
|
|
44
|
+
DayOfWeek.WEDNESDAY,
|
|
45
|
+
DayOfWeek.THURSDAY,
|
|
46
|
+
DayOfWeek.FRIDAY,
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_days(days_str):
|
|
51
|
+
days = []
|
|
52
|
+
for d in days_str.split(","):
|
|
53
|
+
d = d.strip().lower()
|
|
54
|
+
if d not in DAY_MAP:
|
|
55
|
+
raise ValueError(d)
|
|
56
|
+
days.append(DAY_MAP[d])
|
|
57
|
+
return days
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_recurrence(expr):
|
|
61
|
+
if expr is None:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
expr = expr.strip()
|
|
65
|
+
if not expr:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
# Split on ":" to separate base from day specifiers
|
|
69
|
+
parts = expr.split(":", 1)
|
|
70
|
+
base_part = parts[0].strip().lower()
|
|
71
|
+
days_part = parts[1].strip() if len(parts) > 1 else None
|
|
72
|
+
|
|
73
|
+
pattern_type = None
|
|
74
|
+
interval = 1
|
|
75
|
+
|
|
76
|
+
# Check for "weekdays" preset
|
|
77
|
+
if base_part == "weekdays":
|
|
78
|
+
pattern_type = RecurrencePatternType.WEEKLY
|
|
79
|
+
interval = 1
|
|
80
|
+
days_part = days_part or "mon,tue,wed,thu,fri"
|
|
81
|
+
|
|
82
|
+
# Check for simple presets
|
|
83
|
+
elif base_part in PRESET_MAP:
|
|
84
|
+
pattern_type, interval = PRESET_MAP[base_part]
|
|
85
|
+
|
|
86
|
+
# Check for "every N unit" format
|
|
87
|
+
else:
|
|
88
|
+
match = re.match(r"^every\s+(\d+)\s+(days?|weeks?|months?|years?)$", base_part)
|
|
89
|
+
if match:
|
|
90
|
+
interval = int(match.group(1))
|
|
91
|
+
unit = match.group(2)
|
|
92
|
+
pattern_type = UNIT_MAP[unit]
|
|
93
|
+
else:
|
|
94
|
+
raise InvalidRecurrenceExpression(expr)
|
|
95
|
+
|
|
96
|
+
# Build pattern
|
|
97
|
+
today = date.today()
|
|
98
|
+
pattern = {
|
|
99
|
+
"type": pattern_type.value,
|
|
100
|
+
"interval": interval,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if days_part:
|
|
104
|
+
try:
|
|
105
|
+
pattern["daysOfWeek"] = [d.value for d in _parse_days(days_part)]
|
|
106
|
+
except ValueError:
|
|
107
|
+
raise InvalidRecurrenceExpression(expr)
|
|
108
|
+
|
|
109
|
+
if pattern_type == RecurrencePatternType.ABSOLUTE_MONTHLY:
|
|
110
|
+
pattern["dayOfMonth"] = today.day
|
|
111
|
+
|
|
112
|
+
if pattern_type == RecurrencePatternType.ABSOLUTE_YEARLY:
|
|
113
|
+
pattern["dayOfMonth"] = today.day
|
|
114
|
+
pattern["month"] = today.month
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"pattern": pattern,
|
|
118
|
+
"range": {
|
|
119
|
+
"type": "noEnd",
|
|
120
|
+
"startDate": today.isoformat(),
|
|
121
|
+
},
|
|
122
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import yaml
|
|
3
|
+
import requests
|
|
4
|
+
import todocli
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
DATE_FORMAT = "%Y%m%d"
|
|
8
|
+
PYPI_PACKAGE = "microsoft-todo-cli"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check():
|
|
12
|
+
"""Check for updates once per day, silently fail on errors."""
|
|
13
|
+
config_dir = "{}/.config/tod0".format(os.path.expanduser("~"))
|
|
14
|
+
if not os.path.isdir(config_dir):
|
|
15
|
+
os.makedirs(config_dir)
|
|
16
|
+
|
|
17
|
+
last_update_check = datetime(1990, 1, 1)
|
|
18
|
+
file_path = os.path.join(config_dir, "data.yml")
|
|
19
|
+
if os.path.isfile(file_path):
|
|
20
|
+
try:
|
|
21
|
+
with open(file_path, "r") as f:
|
|
22
|
+
data = yaml.load(f, yaml.SafeLoader)
|
|
23
|
+
if data and "last_update_check" in data:
|
|
24
|
+
last_update_check = datetime.strptime(
|
|
25
|
+
data["last_update_check"], DATE_FORMAT
|
|
26
|
+
)
|
|
27
|
+
except (OSError, yaml.YAMLError, ValueError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
# Check for updates if it has been a day since last check
|
|
31
|
+
if last_update_check + timedelta(days=1) < datetime.now():
|
|
32
|
+
try:
|
|
33
|
+
response = requests.get(
|
|
34
|
+
f"https://pypi.org/pypi/{PYPI_PACKAGE}/json",
|
|
35
|
+
timeout=5,
|
|
36
|
+
)
|
|
37
|
+
if response.ok:
|
|
38
|
+
data = response.json()
|
|
39
|
+
latest_version = data.get("info", {}).get("version", "0.0.0")
|
|
40
|
+
latest_tuple = tuple(map(int, latest_version.split(".")[:3]))
|
|
41
|
+
current_tuple = tuple(map(int, todocli.__version__.split(".")[:3]))
|
|
42
|
+
|
|
43
|
+
if latest_tuple > current_tuple:
|
|
44
|
+
print(
|
|
45
|
+
f"Update available: {todocli.__version__} -> {latest_version}. "
|
|
46
|
+
f'Run "pip install --upgrade {PYPI_PACKAGE}"'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
with open(file_path, "w") as f:
|
|
50
|
+
yaml.dump(
|
|
51
|
+
{"last_update_check": datetime.now().strftime(DATE_FORMAT)}, f
|
|
52
|
+
)
|
|
53
|
+
except (requests.RequestException, ValueError, KeyError, OSError):
|
|
54
|
+
# Silently ignore update check failures
|
|
55
|
+
pass
|