iflow-mcp_democratize-technology-chronos-mcp 2.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.
- chronos_mcp/__init__.py +5 -0
- chronos_mcp/__main__.py +9 -0
- chronos_mcp/accounts.py +410 -0
- chronos_mcp/bulk.py +946 -0
- chronos_mcp/caldav_utils.py +149 -0
- chronos_mcp/calendars.py +204 -0
- chronos_mcp/config.py +187 -0
- chronos_mcp/credentials.py +190 -0
- chronos_mcp/events.py +515 -0
- chronos_mcp/exceptions.py +477 -0
- chronos_mcp/journals.py +477 -0
- chronos_mcp/logging_config.py +23 -0
- chronos_mcp/models.py +202 -0
- chronos_mcp/py.typed +0 -0
- chronos_mcp/rrule.py +259 -0
- chronos_mcp/search.py +315 -0
- chronos_mcp/server.py +121 -0
- chronos_mcp/tasks.py +518 -0
- chronos_mcp/tools/__init__.py +29 -0
- chronos_mcp/tools/accounts.py +151 -0
- chronos_mcp/tools/base.py +59 -0
- chronos_mcp/tools/bulk.py +557 -0
- chronos_mcp/tools/calendars.py +142 -0
- chronos_mcp/tools/events.py +698 -0
- chronos_mcp/tools/journals.py +310 -0
- chronos_mcp/tools/tasks.py +414 -0
- chronos_mcp/utils.py +163 -0
- chronos_mcp/validation.py +636 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/METADATA +299 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/RECORD +68 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/WHEEL +5 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +91 -0
- tests/unit/__init__.py +0 -0
- tests/unit/test_accounts.py +380 -0
- tests/unit/test_accounts_ssrf.py +134 -0
- tests/unit/test_base.py +135 -0
- tests/unit/test_bulk.py +380 -0
- tests/unit/test_bulk_create.py +408 -0
- tests/unit/test_bulk_delete.py +341 -0
- tests/unit/test_bulk_resource_limits.py +74 -0
- tests/unit/test_caldav_utils.py +300 -0
- tests/unit/test_calendars.py +286 -0
- tests/unit/test_config.py +111 -0
- tests/unit/test_config_validation.py +128 -0
- tests/unit/test_credentials_security.py +189 -0
- tests/unit/test_cryptography_security.py +178 -0
- tests/unit/test_events.py +536 -0
- tests/unit/test_exceptions.py +58 -0
- tests/unit/test_journals.py +1097 -0
- tests/unit/test_models.py +95 -0
- tests/unit/test_race_conditions.py +202 -0
- tests/unit/test_recurring_events.py +156 -0
- tests/unit/test_rrule.py +217 -0
- tests/unit/test_search.py +372 -0
- tests/unit/test_search_advanced.py +333 -0
- tests/unit/test_server_input_validation.py +219 -0
- tests/unit/test_ssrf_protection.py +505 -0
- tests/unit/test_tasks.py +918 -0
- tests/unit/test_thread_safety.py +301 -0
- tests/unit/test_tools_journals.py +617 -0
- tests/unit/test_tools_tasks.py +968 -0
- tests/unit/test_url_validation_security.py +234 -0
- tests/unit/test_utils.py +180 -0
- tests/unit/test_validation.py +983 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Task management tools for Chronos MCP
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any, Dict, List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from ..exceptions import (
|
|
11
|
+
CalendarNotFoundError,
|
|
12
|
+
ChronosError,
|
|
13
|
+
ErrorSanitizer,
|
|
14
|
+
EventCreationError,
|
|
15
|
+
EventNotFoundError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from ..logging_config import setup_logging
|
|
19
|
+
from ..models import TaskStatus
|
|
20
|
+
from ..utils import parse_datetime
|
|
21
|
+
from ..validation import InputValidator
|
|
22
|
+
from .base import create_success_response, handle_tool_errors
|
|
23
|
+
|
|
24
|
+
logger = setup_logging()
|
|
25
|
+
|
|
26
|
+
# Module-level managers dictionary for dependency injection
|
|
27
|
+
_managers = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Task tool functions - defined as standalone functions for importability
|
|
31
|
+
async def create_task(
|
|
32
|
+
calendar_uid: str = Field(..., description="Calendar UID"),
|
|
33
|
+
summary: str = Field(..., description="Task title/summary"),
|
|
34
|
+
description: Optional[str] = Field(None, description="Task description"),
|
|
35
|
+
due: Optional[str] = Field(None, description="Task due date (ISO format)"),
|
|
36
|
+
priority: Optional[Union[int, str]] = Field(
|
|
37
|
+
None, description="Task priority (1-9, 1 is highest)"
|
|
38
|
+
),
|
|
39
|
+
status: str = Field(
|
|
40
|
+
"NEEDS-ACTION",
|
|
41
|
+
description="Task status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)",
|
|
42
|
+
),
|
|
43
|
+
related_to: Optional[List[str]] = Field(
|
|
44
|
+
None, description="List of related component UIDs"
|
|
45
|
+
),
|
|
46
|
+
account: Optional[str] = Field(None, description="Account alias"),
|
|
47
|
+
) -> Dict[str, Any]:
|
|
48
|
+
"""Create a new task"""
|
|
49
|
+
request_id = str(uuid.uuid4())
|
|
50
|
+
|
|
51
|
+
# Handle type conversion for parameters that might come as strings from MCP
|
|
52
|
+
if priority is not None:
|
|
53
|
+
try:
|
|
54
|
+
priority = int(priority)
|
|
55
|
+
except (ValueError, TypeError):
|
|
56
|
+
return {
|
|
57
|
+
"success": False,
|
|
58
|
+
"error": f"Invalid priority value: {priority}. Must be an integer between 1 and 9",
|
|
59
|
+
"error_code": "VALIDATION_ERROR",
|
|
60
|
+
"request_id": request_id,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
# Validate and sanitize text inputs
|
|
65
|
+
try:
|
|
66
|
+
summary = InputValidator.validate_text_field(
|
|
67
|
+
summary, "summary", required=True
|
|
68
|
+
)
|
|
69
|
+
if description:
|
|
70
|
+
description = InputValidator.validate_text_field(
|
|
71
|
+
description, "description"
|
|
72
|
+
)
|
|
73
|
+
except ValidationError as e:
|
|
74
|
+
return {
|
|
75
|
+
"success": False,
|
|
76
|
+
"error": str(e),
|
|
77
|
+
"error_code": "VALIDATION_ERROR",
|
|
78
|
+
"request_id": request_id,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Parse due date if provided
|
|
82
|
+
due_dt = None
|
|
83
|
+
if due:
|
|
84
|
+
due_dt = parse_datetime(due)
|
|
85
|
+
|
|
86
|
+
# Validate priority
|
|
87
|
+
if priority is not None and not (1 <= priority <= 9):
|
|
88
|
+
return {
|
|
89
|
+
"success": False,
|
|
90
|
+
"error": "Priority must be between 1 and 9",
|
|
91
|
+
"error_code": "VALIDATION_ERROR",
|
|
92
|
+
"request_id": request_id,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Parse status
|
|
96
|
+
try:
|
|
97
|
+
task_status = TaskStatus(status)
|
|
98
|
+
except ValueError:
|
|
99
|
+
return {
|
|
100
|
+
"success": False,
|
|
101
|
+
"error": f"Invalid status: {status}. Must be one of: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED",
|
|
102
|
+
"error_code": "VALIDATION_ERROR",
|
|
103
|
+
"request_id": request_id,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
task = _managers["task_manager"].create_task(
|
|
107
|
+
calendar_uid=calendar_uid,
|
|
108
|
+
summary=summary,
|
|
109
|
+
description=description,
|
|
110
|
+
due=due_dt,
|
|
111
|
+
priority=priority,
|
|
112
|
+
status=task_status,
|
|
113
|
+
related_to=related_to,
|
|
114
|
+
account_alias=account,
|
|
115
|
+
request_id=request_id,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"success": True,
|
|
120
|
+
"task": {
|
|
121
|
+
"uid": task.uid,
|
|
122
|
+
"summary": task.summary,
|
|
123
|
+
"description": task.description,
|
|
124
|
+
"due": task.due.isoformat() if task.due else None,
|
|
125
|
+
"priority": task.priority,
|
|
126
|
+
"status": task.status.value,
|
|
127
|
+
"percent_complete": task.percent_complete,
|
|
128
|
+
"related_to": task.related_to,
|
|
129
|
+
},
|
|
130
|
+
"request_id": request_id,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
except (CalendarNotFoundError, EventCreationError) as e:
|
|
134
|
+
e.request_id = request_id
|
|
135
|
+
logger.error(f"Task creation error: {e}")
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"success": False,
|
|
139
|
+
"error": ErrorSanitizer.get_user_friendly_message(e),
|
|
140
|
+
"error_code": e.error_code,
|
|
141
|
+
"request_id": request_id,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
except ChronosError as e:
|
|
145
|
+
e.request_id = request_id
|
|
146
|
+
logger.error(f"Create task failed: {e}")
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
"success": False,
|
|
150
|
+
"error": ErrorSanitizer.get_user_friendly_message(e),
|
|
151
|
+
"error_code": e.error_code,
|
|
152
|
+
"request_id": request_id,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
chronos_error = ChronosError(
|
|
157
|
+
message=f"Failed to create task: {str(e)}",
|
|
158
|
+
details={
|
|
159
|
+
"tool": "create_task",
|
|
160
|
+
"summary": summary,
|
|
161
|
+
"calendar_uid": calendar_uid,
|
|
162
|
+
"original_error": str(e),
|
|
163
|
+
"original_type": type(e).__name__,
|
|
164
|
+
},
|
|
165
|
+
request_id=request_id,
|
|
166
|
+
)
|
|
167
|
+
logger.error(f"Unexpected error in create_task: {chronos_error}")
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"success": False,
|
|
171
|
+
"error": ErrorSanitizer.get_user_friendly_message(chronos_error),
|
|
172
|
+
"error_code": chronos_error.error_code,
|
|
173
|
+
"request_id": request_id,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def list_tasks(
|
|
178
|
+
calendar_uid: str = Field(..., description="Calendar UID"),
|
|
179
|
+
status_filter: Optional[str] = Field(
|
|
180
|
+
None,
|
|
181
|
+
description="Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)",
|
|
182
|
+
),
|
|
183
|
+
account: Optional[str] = Field(None, description="Account alias"),
|
|
184
|
+
) -> Dict[str, Any]:
|
|
185
|
+
"""List tasks in a calendar"""
|
|
186
|
+
request_id = str(uuid.uuid4())
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
# Parse status filter if provided
|
|
190
|
+
status_enum = None
|
|
191
|
+
if status_filter:
|
|
192
|
+
try:
|
|
193
|
+
status_enum = TaskStatus(status_filter)
|
|
194
|
+
except ValueError:
|
|
195
|
+
return {
|
|
196
|
+
"success": False,
|
|
197
|
+
"error": f"Invalid status filter: {status_filter}. Must be one of: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED",
|
|
198
|
+
"error_code": "VALIDATION_ERROR",
|
|
199
|
+
"request_id": request_id,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
tasks = _managers["task_manager"].list_tasks(
|
|
203
|
+
calendar_uid=calendar_uid,
|
|
204
|
+
status_filter=status_enum,
|
|
205
|
+
account_alias=account,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
"tasks": [
|
|
210
|
+
{
|
|
211
|
+
"uid": task.uid,
|
|
212
|
+
"summary": task.summary,
|
|
213
|
+
"description": task.description,
|
|
214
|
+
"due": task.due.isoformat() if task.due else None,
|
|
215
|
+
"priority": task.priority,
|
|
216
|
+
"status": task.status.value,
|
|
217
|
+
"percent_complete": task.percent_complete,
|
|
218
|
+
"related_to": task.related_to,
|
|
219
|
+
}
|
|
220
|
+
for task in tasks
|
|
221
|
+
],
|
|
222
|
+
"total": len(tasks),
|
|
223
|
+
"calendar_uid": calendar_uid,
|
|
224
|
+
"request_id": request_id,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
except CalendarNotFoundError as e:
|
|
228
|
+
e.request_id = request_id
|
|
229
|
+
logger.error(f"Calendar not found for task listing: {e}")
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
"tasks": [],
|
|
233
|
+
"total": 0,
|
|
234
|
+
"error": ErrorSanitizer.get_user_friendly_message(e),
|
|
235
|
+
"error_code": e.error_code,
|
|
236
|
+
"request_id": request_id,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
except ChronosError as e:
|
|
240
|
+
e.request_id = request_id
|
|
241
|
+
logger.error(f"List tasks failed: {e}")
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
"tasks": [],
|
|
245
|
+
"total": 0,
|
|
246
|
+
"error": ErrorSanitizer.get_user_friendly_message(e),
|
|
247
|
+
"error_code": e.error_code,
|
|
248
|
+
"request_id": request_id,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
except Exception as e:
|
|
252
|
+
chronos_error = ChronosError(
|
|
253
|
+
message=f"Failed to list tasks: {str(e)}",
|
|
254
|
+
details={
|
|
255
|
+
"tool": "list_tasks",
|
|
256
|
+
"calendar_uid": calendar_uid,
|
|
257
|
+
"original_error": str(e),
|
|
258
|
+
"original_type": type(e).__name__,
|
|
259
|
+
},
|
|
260
|
+
request_id=request_id,
|
|
261
|
+
)
|
|
262
|
+
logger.error(f"Unexpected error in list_tasks: {chronos_error}")
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
"tasks": [],
|
|
266
|
+
"total": 0,
|
|
267
|
+
"error": ErrorSanitizer.get_user_friendly_message(chronos_error),
|
|
268
|
+
"error_code": chronos_error.error_code,
|
|
269
|
+
"request_id": request_id,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@handle_tool_errors
|
|
274
|
+
async def update_task(
|
|
275
|
+
calendar_uid: str = Field(..., description="Calendar UID"),
|
|
276
|
+
task_uid: str = Field(..., description="Task UID to update"),
|
|
277
|
+
summary: Optional[str] = Field(None, description="Task title/summary"),
|
|
278
|
+
description: Optional[str] = Field(None, description="Task description"),
|
|
279
|
+
due: Optional[str] = Field(None, description="Task due date (ISO format)"),
|
|
280
|
+
priority: Optional[Union[int, str]] = Field(
|
|
281
|
+
None, description="Task priority (1-9, 1 is highest)"
|
|
282
|
+
),
|
|
283
|
+
status: Optional[str] = Field(None, description="Task status"),
|
|
284
|
+
percent_complete: Optional[Union[int, str]] = Field(
|
|
285
|
+
None, description="Completion percentage (0-100)"
|
|
286
|
+
),
|
|
287
|
+
account: Optional[str] = Field(None, description="Account alias"),
|
|
288
|
+
request_id: str = None,
|
|
289
|
+
) -> Dict[str, Any]:
|
|
290
|
+
"""Update an existing task. Only provided fields will be updated."""
|
|
291
|
+
# Handle type conversion for parameters that might come as strings from MCP
|
|
292
|
+
if priority is not None:
|
|
293
|
+
try:
|
|
294
|
+
priority = int(priority)
|
|
295
|
+
except (ValueError, TypeError):
|
|
296
|
+
raise ValidationError(
|
|
297
|
+
f"Invalid priority value: {priority}. Must be an integer between 1 and 9"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if percent_complete is not None:
|
|
301
|
+
try:
|
|
302
|
+
percent_complete = int(percent_complete)
|
|
303
|
+
except (ValueError, TypeError):
|
|
304
|
+
raise ValidationError(
|
|
305
|
+
f"Invalid percent_complete value: {percent_complete}. Must be an integer between 0 and 100"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Validate and parse inputs
|
|
309
|
+
if summary is not None:
|
|
310
|
+
summary = InputValidator.validate_text_field(summary, "summary", required=True)
|
|
311
|
+
if description is not None:
|
|
312
|
+
description = InputValidator.validate_text_field(description, "description")
|
|
313
|
+
|
|
314
|
+
# Parse due date if provided
|
|
315
|
+
due_dt = None
|
|
316
|
+
if due is not None:
|
|
317
|
+
due_dt = parse_datetime(due)
|
|
318
|
+
|
|
319
|
+
# Validate priority
|
|
320
|
+
if priority is not None and not (1 <= priority <= 9):
|
|
321
|
+
raise ValidationError("Priority must be between 1 and 9")
|
|
322
|
+
|
|
323
|
+
# Parse status
|
|
324
|
+
status_enum = None
|
|
325
|
+
if status is not None:
|
|
326
|
+
try:
|
|
327
|
+
status_enum = TaskStatus(status)
|
|
328
|
+
except ValueError:
|
|
329
|
+
raise ValidationError(
|
|
330
|
+
f"Invalid status: {status}. Must be one of: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Validate percent_complete
|
|
334
|
+
if percent_complete is not None and not (0 <= percent_complete <= 100):
|
|
335
|
+
raise ValidationError("Percent complete must be between 0 and 100")
|
|
336
|
+
|
|
337
|
+
updated_task = _managers["task_manager"].update_task(
|
|
338
|
+
calendar_uid=calendar_uid,
|
|
339
|
+
task_uid=task_uid,
|
|
340
|
+
summary=summary,
|
|
341
|
+
description=description,
|
|
342
|
+
due=due_dt,
|
|
343
|
+
priority=priority,
|
|
344
|
+
status=status_enum,
|
|
345
|
+
percent_complete=percent_complete,
|
|
346
|
+
account_alias=account,
|
|
347
|
+
request_id=request_id,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
return create_success_response(
|
|
351
|
+
message=f"Task '{task_uid}' updated successfully",
|
|
352
|
+
request_id=request_id,
|
|
353
|
+
task={
|
|
354
|
+
"uid": updated_task.uid,
|
|
355
|
+
"summary": updated_task.summary,
|
|
356
|
+
"description": updated_task.description,
|
|
357
|
+
"due": updated_task.due.isoformat() if updated_task.due else None,
|
|
358
|
+
"priority": updated_task.priority,
|
|
359
|
+
"status": updated_task.status.value,
|
|
360
|
+
"percent_complete": updated_task.percent_complete,
|
|
361
|
+
"related_to": updated_task.related_to,
|
|
362
|
+
},
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@handle_tool_errors
|
|
367
|
+
async def delete_task(
|
|
368
|
+
calendar_uid: str = Field(..., description="Calendar UID"),
|
|
369
|
+
task_uid: str = Field(..., description="Task UID to delete"),
|
|
370
|
+
account: Optional[str] = Field(None, description="Account alias"),
|
|
371
|
+
request_id: str = None,
|
|
372
|
+
) -> Dict[str, Any]:
|
|
373
|
+
"""Delete a task"""
|
|
374
|
+
_managers["task_manager"].delete_task(
|
|
375
|
+
calendar_uid=calendar_uid,
|
|
376
|
+
task_uid=task_uid,
|
|
377
|
+
account_alias=account,
|
|
378
|
+
request_id=request_id,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
return create_success_response(
|
|
382
|
+
message=f"Task '{task_uid}' deleted successfully",
|
|
383
|
+
request_id=request_id,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def register_task_tools(mcp, managers):
|
|
388
|
+
"""Register task management tools with the MCP server"""
|
|
389
|
+
|
|
390
|
+
# Update module-level managers for dependency injection
|
|
391
|
+
_managers.update(managers)
|
|
392
|
+
|
|
393
|
+
# Register all task tools with the MCP server
|
|
394
|
+
mcp.tool(create_task)
|
|
395
|
+
mcp.tool(list_tasks)
|
|
396
|
+
mcp.tool(update_task)
|
|
397
|
+
mcp.tool(delete_task)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# Add .fn attribute to each function for backwards compatibility with tests
|
|
401
|
+
create_task.fn = create_task
|
|
402
|
+
list_tasks.fn = list_tasks
|
|
403
|
+
update_task.fn = update_task
|
|
404
|
+
delete_task.fn = delete_task
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# Export all tools for backwards compatibility
|
|
408
|
+
__all__ = [
|
|
409
|
+
"create_task",
|
|
410
|
+
"list_tasks",
|
|
411
|
+
"update_task",
|
|
412
|
+
"delete_task",
|
|
413
|
+
"register_task_tools",
|
|
414
|
+
]
|
chronos_mcp/utils.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for Chronos MCP
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
from dateutil import parser
|
|
9
|
+
from icalendar import Event as iEvent
|
|
10
|
+
|
|
11
|
+
from .logging_config import setup_logging
|
|
12
|
+
|
|
13
|
+
logger = setup_logging()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_datetime(dt_str: Union[str, datetime]) -> datetime:
|
|
17
|
+
"""Parse datetime string or return datetime object"""
|
|
18
|
+
if isinstance(dt_str, datetime):
|
|
19
|
+
return dt_str
|
|
20
|
+
|
|
21
|
+
# Try parsing with dateutil
|
|
22
|
+
try:
|
|
23
|
+
dt = parser.parse(dt_str)
|
|
24
|
+
# Ensure timezone awareness
|
|
25
|
+
if dt.tzinfo is None:
|
|
26
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
27
|
+
return dt
|
|
28
|
+
except Exception as e:
|
|
29
|
+
logger.error(f"Error parsing datetime '{dt_str}': {e}")
|
|
30
|
+
raise ValueError(f"Invalid datetime format: {dt_str}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def datetime_to_ical(dt: datetime, all_day: bool = False) -> str:
|
|
34
|
+
"""Convert datetime to iCalendar format"""
|
|
35
|
+
if all_day:
|
|
36
|
+
return dt.strftime("%Y%m%d")
|
|
37
|
+
else:
|
|
38
|
+
# Ensure UTC timezone
|
|
39
|
+
if dt.tzinfo is None:
|
|
40
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
41
|
+
elif dt.tzinfo != timezone.utc:
|
|
42
|
+
dt = dt.astimezone(timezone.utc)
|
|
43
|
+
return dt.strftime("%Y%m%dT%H%M%SZ")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ical_to_datetime(ical_dt) -> datetime:
|
|
47
|
+
"""Convert iCalendar datetime to Python datetime"""
|
|
48
|
+
if hasattr(ical_dt, "dt"):
|
|
49
|
+
dt = ical_dt.dt
|
|
50
|
+
else:
|
|
51
|
+
dt = ical_dt
|
|
52
|
+
|
|
53
|
+
# Handle date-only (all-day events)
|
|
54
|
+
if not isinstance(dt, datetime):
|
|
55
|
+
dt = datetime.combine(dt, datetime.min.time())
|
|
56
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
57
|
+
|
|
58
|
+
# Ensure timezone awareness
|
|
59
|
+
if dt.tzinfo is None:
|
|
60
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
61
|
+
|
|
62
|
+
return dt
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def create_ical_event(event_data: dict) -> iEvent:
|
|
66
|
+
"""Create iCalendar event from data"""
|
|
67
|
+
event = iEvent()
|
|
68
|
+
|
|
69
|
+
# Required fields
|
|
70
|
+
event.add("uid", event_data.get("uid"))
|
|
71
|
+
event.add("summary", event_data.get("summary"))
|
|
72
|
+
event.add("dtstart", event_data.get("start"))
|
|
73
|
+
event.add("dtend", event_data.get("end"))
|
|
74
|
+
|
|
75
|
+
# Optional fields
|
|
76
|
+
if "description" in event_data:
|
|
77
|
+
event.add("description", event_data["description"])
|
|
78
|
+
if "location" in event_data:
|
|
79
|
+
event.add("location", event_data["location"])
|
|
80
|
+
if "status" in event_data:
|
|
81
|
+
event.add("status", event_data["status"])
|
|
82
|
+
|
|
83
|
+
return event
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def validate_rrule(rrule: str) -> Tuple[bool, Optional[str]]:
|
|
87
|
+
"""
|
|
88
|
+
Validate RRULE syntax according to RFC 5545.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
rrule: The RRULE string to validate
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
tuple: (is_valid, error_message)
|
|
95
|
+
"""
|
|
96
|
+
if not rrule:
|
|
97
|
+
return True, None
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
# Basic validation - must have FREQ
|
|
101
|
+
if not rrule.startswith("FREQ="):
|
|
102
|
+
return False, "RRULE must start with FREQ="
|
|
103
|
+
|
|
104
|
+
# Parse components
|
|
105
|
+
parts = rrule.split(";")
|
|
106
|
+
rules = {}
|
|
107
|
+
|
|
108
|
+
for part in parts:
|
|
109
|
+
if "=" not in part:
|
|
110
|
+
return False, f"Invalid RRULE component: {part}"
|
|
111
|
+
|
|
112
|
+
key, value = part.split("=", 1)
|
|
113
|
+
rules[key] = value
|
|
114
|
+
|
|
115
|
+
# Validate FREQ is present and valid
|
|
116
|
+
if "FREQ" not in rules:
|
|
117
|
+
return False, "FREQ is required in RRULE"
|
|
118
|
+
|
|
119
|
+
valid_freqs = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"]
|
|
120
|
+
if rules["FREQ"] not in valid_freqs:
|
|
121
|
+
return (
|
|
122
|
+
False,
|
|
123
|
+
f"Invalid FREQ value: {rules['FREQ']}. Must be one of {valid_freqs}",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Validate other common components
|
|
127
|
+
if "INTERVAL" in rules:
|
|
128
|
+
try:
|
|
129
|
+
interval = int(rules["INTERVAL"])
|
|
130
|
+
if interval < 1:
|
|
131
|
+
return False, "INTERVAL must be a positive integer"
|
|
132
|
+
except ValueError:
|
|
133
|
+
return False, "INTERVAL must be an integer"
|
|
134
|
+
|
|
135
|
+
if "COUNT" in rules:
|
|
136
|
+
try:
|
|
137
|
+
count = int(rules["COUNT"])
|
|
138
|
+
if count < 1:
|
|
139
|
+
return False, "COUNT must be a positive integer"
|
|
140
|
+
except ValueError:
|
|
141
|
+
return False, "COUNT must be an integer"
|
|
142
|
+
|
|
143
|
+
if "UNTIL" in rules:
|
|
144
|
+
# Basic format check for UNTIL (should be datetime)
|
|
145
|
+
until = rules["UNTIL"]
|
|
146
|
+
if not (len(until) >= 8 and until[0:8].isdigit()):
|
|
147
|
+
return False, "UNTIL must be in YYYYMMDD or YYYYMMDDTHHMMSSZ format"
|
|
148
|
+
|
|
149
|
+
if "BYDAY" in rules:
|
|
150
|
+
# Validate day abbreviations
|
|
151
|
+
valid_days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
|
|
152
|
+
days = rules["BYDAY"].split(",")
|
|
153
|
+
for day in days:
|
|
154
|
+
# Remove position prefix if present (e.g., 2MO for 2nd Monday)
|
|
155
|
+
day_abbr = day.lstrip("-+0123456789")
|
|
156
|
+
if day_abbr not in valid_days:
|
|
157
|
+
return False, f"Invalid day abbreviation: {day}"
|
|
158
|
+
|
|
159
|
+
# If we get here, basic validation passed
|
|
160
|
+
return True, None
|
|
161
|
+
|
|
162
|
+
except Exception as e:
|
|
163
|
+
return False, f"Error parsing RRULE: {str(e)}"
|