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
chronos_mcp/journals.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Journal operations for Chronos MCP
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
import caldav
|
|
10
|
+
from caldav import Event as CalDAVEvent
|
|
11
|
+
from icalendar import Calendar as iCalendar
|
|
12
|
+
from icalendar import Journal as iJournal
|
|
13
|
+
|
|
14
|
+
from .caldav_utils import get_item_with_fallback
|
|
15
|
+
from .calendars import CalendarManager
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
CalendarNotFoundError,
|
|
18
|
+
ChronosError,
|
|
19
|
+
EventCreationError,
|
|
20
|
+
EventDeletionError,
|
|
21
|
+
JournalNotFoundError,
|
|
22
|
+
)
|
|
23
|
+
from .logging_config import setup_logging
|
|
24
|
+
from .models import Journal
|
|
25
|
+
from .utils import ical_to_datetime
|
|
26
|
+
|
|
27
|
+
logger = setup_logging()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JournalManager:
|
|
31
|
+
"""Manage calendar journals (VJOURNAL)"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, calendar_manager: CalendarManager):
|
|
34
|
+
self.calendars = calendar_manager
|
|
35
|
+
|
|
36
|
+
def _get_default_account(self) -> Optional[str]:
|
|
37
|
+
try:
|
|
38
|
+
return self.calendars.accounts.config.config.default_account
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def create_journal(
|
|
43
|
+
self,
|
|
44
|
+
calendar_uid: str,
|
|
45
|
+
summary: str,
|
|
46
|
+
description: Optional[str] = None,
|
|
47
|
+
dtstart: Optional[datetime] = None,
|
|
48
|
+
related_to: Optional[List[str]] = None,
|
|
49
|
+
account_alias: Optional[str] = None,
|
|
50
|
+
request_id: Optional[str] = None,
|
|
51
|
+
) -> Optional[Journal]:
|
|
52
|
+
"""Create a new journal entry - raises exceptions on failure"""
|
|
53
|
+
request_id = request_id or str(uuid.uuid4())
|
|
54
|
+
|
|
55
|
+
calendar = self.calendars.get_calendar(
|
|
56
|
+
calendar_uid, account_alias, request_id=request_id
|
|
57
|
+
)
|
|
58
|
+
if not calendar:
|
|
59
|
+
raise CalendarNotFoundError(
|
|
60
|
+
calendar_uid, account_alias, request_id=request_id
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
# Use current time if dtstart not provided
|
|
65
|
+
if dtstart is None:
|
|
66
|
+
dtstart = datetime.now(timezone.utc)
|
|
67
|
+
|
|
68
|
+
cal = iCalendar()
|
|
69
|
+
journal = iJournal()
|
|
70
|
+
|
|
71
|
+
# Generate UID if not provided
|
|
72
|
+
journal_uid = str(uuid.uuid4())
|
|
73
|
+
|
|
74
|
+
journal.add("uid", journal_uid)
|
|
75
|
+
journal.add("summary", summary)
|
|
76
|
+
journal.add("dtstart", dtstart)
|
|
77
|
+
journal.add("dtstamp", datetime.now(timezone.utc))
|
|
78
|
+
|
|
79
|
+
if description:
|
|
80
|
+
journal.add("description", description)
|
|
81
|
+
|
|
82
|
+
if related_to:
|
|
83
|
+
for related_uid in related_to:
|
|
84
|
+
journal.add("related-to", related_uid)
|
|
85
|
+
|
|
86
|
+
cal.add_component(journal)
|
|
87
|
+
|
|
88
|
+
# Save to CalDAV server using component-specific method when available
|
|
89
|
+
ical_data = cal.to_ical().decode("utf-8")
|
|
90
|
+
|
|
91
|
+
if hasattr(calendar, "save_journal"):
|
|
92
|
+
logger.debug(
|
|
93
|
+
"Using calendar.save_journal() for optimized journal creation",
|
|
94
|
+
extra={"request_id": request_id},
|
|
95
|
+
)
|
|
96
|
+
try:
|
|
97
|
+
caldav_journal = calendar.save_journal(ical_data)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.warning(
|
|
100
|
+
f"calendar.save_journal() failed: {e}, falling back to save_event()",
|
|
101
|
+
extra={"request_id": request_id},
|
|
102
|
+
)
|
|
103
|
+
caldav_journal = calendar.save_event(ical_data)
|
|
104
|
+
else:
|
|
105
|
+
logger.debug(
|
|
106
|
+
"Server doesn't support calendar.save_journal(), using calendar.save_event()",
|
|
107
|
+
extra={"request_id": request_id},
|
|
108
|
+
)
|
|
109
|
+
caldav_journal = calendar.save_event(ical_data)
|
|
110
|
+
|
|
111
|
+
journal_model = Journal(
|
|
112
|
+
uid=journal_uid,
|
|
113
|
+
summary=summary,
|
|
114
|
+
description=description,
|
|
115
|
+
dtstart=dtstart,
|
|
116
|
+
related_to=related_to or [],
|
|
117
|
+
calendar_uid=calendar_uid,
|
|
118
|
+
account_alias=account_alias or self._get_default_account() or "default",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return journal_model
|
|
122
|
+
|
|
123
|
+
except caldav.lib.error.AuthorizationError as e:
|
|
124
|
+
logger.error(
|
|
125
|
+
f"Authorization error creating journal '{summary}': {e}",
|
|
126
|
+
extra={"request_id": request_id},
|
|
127
|
+
)
|
|
128
|
+
raise EventCreationError(
|
|
129
|
+
summary, "Authorization failed", request_id=request_id
|
|
130
|
+
)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.error(
|
|
133
|
+
f"Error creating journal '{summary}': {e}",
|
|
134
|
+
extra={"request_id": request_id},
|
|
135
|
+
)
|
|
136
|
+
raise EventCreationError(summary, str(e), request_id=request_id)
|
|
137
|
+
|
|
138
|
+
def get_journal(
|
|
139
|
+
self,
|
|
140
|
+
journal_uid: str,
|
|
141
|
+
calendar_uid: str,
|
|
142
|
+
account_alias: Optional[str] = None,
|
|
143
|
+
request_id: Optional[str] = None,
|
|
144
|
+
) -> Optional[Journal]:
|
|
145
|
+
"""Get a specific journal by UID"""
|
|
146
|
+
request_id = request_id or str(uuid.uuid4())
|
|
147
|
+
|
|
148
|
+
calendar = self.calendars.get_calendar(
|
|
149
|
+
calendar_uid, account_alias, request_id=request_id
|
|
150
|
+
)
|
|
151
|
+
if not calendar:
|
|
152
|
+
raise CalendarNotFoundError(
|
|
153
|
+
calendar_uid, account_alias, request_id=request_id
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
# Use utility function to find journal with automatic fallback
|
|
158
|
+
caldav_journal = get_item_with_fallback(
|
|
159
|
+
calendar, journal_uid, "journal", request_id=request_id
|
|
160
|
+
)
|
|
161
|
+
return self._parse_caldav_journal(caldav_journal, calendar_uid, account_alias)
|
|
162
|
+
except ValueError:
|
|
163
|
+
# get_item_with_fallback raises ValueError when not found
|
|
164
|
+
raise JournalNotFoundError(journal_uid, calendar_uid, request_id=request_id)
|
|
165
|
+
|
|
166
|
+
except JournalNotFoundError:
|
|
167
|
+
raise
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.error(
|
|
170
|
+
f"Error getting journal '{journal_uid}': {e}",
|
|
171
|
+
extra={"request_id": request_id},
|
|
172
|
+
)
|
|
173
|
+
raise ChronosError(
|
|
174
|
+
f"Failed to get journal: {str(e)}", request_id=request_id
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def list_journals(
|
|
178
|
+
self,
|
|
179
|
+
calendar_uid: str,
|
|
180
|
+
limit: Optional[int] = None,
|
|
181
|
+
account_alias: Optional[str] = None,
|
|
182
|
+
request_id: Optional[str] = None,
|
|
183
|
+
) -> List[Journal]:
|
|
184
|
+
"""List all journals in a calendar"""
|
|
185
|
+
request_id = request_id or str(uuid.uuid4())
|
|
186
|
+
|
|
187
|
+
calendar = self.calendars.get_calendar(
|
|
188
|
+
calendar_uid, account_alias, request_id=request_id
|
|
189
|
+
)
|
|
190
|
+
if not calendar:
|
|
191
|
+
raise CalendarNotFoundError(
|
|
192
|
+
calendar_uid, account_alias, request_id=request_id
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
journals = []
|
|
196
|
+
try:
|
|
197
|
+
# Try component-specific method first for better performance
|
|
198
|
+
if hasattr(calendar, "journals"):
|
|
199
|
+
try:
|
|
200
|
+
logger.debug(
|
|
201
|
+
"Using calendar.journals() for server-side filtering",
|
|
202
|
+
extra={"request_id": request_id},
|
|
203
|
+
)
|
|
204
|
+
journal_objects = calendar.journals()
|
|
205
|
+
|
|
206
|
+
for caldav_journal in journal_objects:
|
|
207
|
+
journal_data = self._parse_caldav_journal(
|
|
208
|
+
caldav_journal, calendar_uid, account_alias
|
|
209
|
+
)
|
|
210
|
+
if journal_data:
|
|
211
|
+
journals.append(journal_data)
|
|
212
|
+
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logger.warning(
|
|
215
|
+
f"calendar.journals() failed: {e}, falling back to calendar.events()",
|
|
216
|
+
extra={"request_id": request_id},
|
|
217
|
+
)
|
|
218
|
+
# Fall through to fallback method
|
|
219
|
+
raise
|
|
220
|
+
else:
|
|
221
|
+
# Fallback method for servers without journals() support
|
|
222
|
+
logger.debug(
|
|
223
|
+
"Server doesn't support calendar.journals(), using calendar.events() with client-side filtering",
|
|
224
|
+
extra={"request_id": request_id},
|
|
225
|
+
)
|
|
226
|
+
events = calendar.events()
|
|
227
|
+
|
|
228
|
+
for caldav_event in events:
|
|
229
|
+
journal_data = self._parse_caldav_journal(
|
|
230
|
+
caldav_event, calendar_uid, account_alias
|
|
231
|
+
)
|
|
232
|
+
if journal_data:
|
|
233
|
+
journals.append(journal_data)
|
|
234
|
+
|
|
235
|
+
except Exception as e:
|
|
236
|
+
# If journals() method failed, try the fallback approach
|
|
237
|
+
if hasattr(calendar, "journals"):
|
|
238
|
+
try:
|
|
239
|
+
logger.info(
|
|
240
|
+
"Retrying with calendar.events() fallback method",
|
|
241
|
+
extra={"request_id": request_id},
|
|
242
|
+
)
|
|
243
|
+
events = calendar.events()
|
|
244
|
+
|
|
245
|
+
for caldav_event in events:
|
|
246
|
+
journal_data = self._parse_caldav_journal(
|
|
247
|
+
caldav_event, calendar_uid, account_alias
|
|
248
|
+
)
|
|
249
|
+
if journal_data:
|
|
250
|
+
journals.append(journal_data)
|
|
251
|
+
except Exception as fallback_error:
|
|
252
|
+
logger.error(
|
|
253
|
+
f"Error listing journals (both methods failed): {fallback_error}",
|
|
254
|
+
extra={"request_id": request_id},
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
logger.error(
|
|
258
|
+
f"Error listing journals: {e}", extra={"request_id": request_id}
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Apply limit if specified
|
|
262
|
+
if limit and len(journals) > limit:
|
|
263
|
+
journals = journals[:limit]
|
|
264
|
+
|
|
265
|
+
return journals
|
|
266
|
+
|
|
267
|
+
def update_journal(
|
|
268
|
+
self,
|
|
269
|
+
journal_uid: str,
|
|
270
|
+
calendar_uid: str,
|
|
271
|
+
summary: Optional[str] = None,
|
|
272
|
+
description: Optional[str] = None,
|
|
273
|
+
dtstart: Optional[datetime] = None,
|
|
274
|
+
related_to: Optional[List[str]] = None,
|
|
275
|
+
account_alias: Optional[str] = None,
|
|
276
|
+
request_id: Optional[str] = None,
|
|
277
|
+
) -> Optional[Journal]:
|
|
278
|
+
"""Update an existing journal - raises exceptions on failure"""
|
|
279
|
+
request_id = request_id or str(uuid.uuid4())
|
|
280
|
+
|
|
281
|
+
calendar = self.calendars.get_calendar(
|
|
282
|
+
calendar_uid, account_alias, request_id=request_id
|
|
283
|
+
)
|
|
284
|
+
if not calendar:
|
|
285
|
+
raise CalendarNotFoundError(
|
|
286
|
+
calendar_uid, account_alias, request_id=request_id
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
# Use utility function to find journal with automatic fallback
|
|
291
|
+
try:
|
|
292
|
+
caldav_journal = get_item_with_fallback(
|
|
293
|
+
calendar, journal_uid, "journal", request_id=request_id
|
|
294
|
+
)
|
|
295
|
+
except ValueError:
|
|
296
|
+
raise JournalNotFoundError(journal_uid, calendar_uid, request_id=request_id)
|
|
297
|
+
|
|
298
|
+
# Parse existing journal data
|
|
299
|
+
ical = iCalendar.from_ical(caldav_journal.data)
|
|
300
|
+
existing_journal = None
|
|
301
|
+
|
|
302
|
+
for component in ical.walk():
|
|
303
|
+
if component.name == "VJOURNAL":
|
|
304
|
+
existing_journal = component
|
|
305
|
+
break
|
|
306
|
+
|
|
307
|
+
if not existing_journal:
|
|
308
|
+
raise EventCreationError(
|
|
309
|
+
f"Journal {journal_uid}",
|
|
310
|
+
"Could not parse existing journal data",
|
|
311
|
+
request_id=request_id,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Update only provided fields
|
|
315
|
+
if summary is not None:
|
|
316
|
+
existing_journal["SUMMARY"] = summary
|
|
317
|
+
|
|
318
|
+
if description is not None:
|
|
319
|
+
if description:
|
|
320
|
+
existing_journal["DESCRIPTION"] = description
|
|
321
|
+
elif "DESCRIPTION" in existing_journal:
|
|
322
|
+
del existing_journal["DESCRIPTION"]
|
|
323
|
+
|
|
324
|
+
if dtstart is not None:
|
|
325
|
+
if "DTSTART" in existing_journal:
|
|
326
|
+
del existing_journal["DTSTART"]
|
|
327
|
+
existing_journal.add("DTSTART", dtstart)
|
|
328
|
+
|
|
329
|
+
# Handle RELATED-TO property updates
|
|
330
|
+
if related_to is not None:
|
|
331
|
+
# Remove all existing RELATED-TO properties
|
|
332
|
+
if "RELATED-TO" in existing_journal:
|
|
333
|
+
del existing_journal["RELATED-TO"]
|
|
334
|
+
|
|
335
|
+
# Add new RELATED-TO properties if provided
|
|
336
|
+
if related_to:
|
|
337
|
+
for related_uid in related_to:
|
|
338
|
+
existing_journal.add("RELATED-TO", related_uid)
|
|
339
|
+
|
|
340
|
+
# Update last-modified timestamp
|
|
341
|
+
if "LAST-MODIFIED" in existing_journal:
|
|
342
|
+
del existing_journal["LAST-MODIFIED"]
|
|
343
|
+
existing_journal.add("LAST-MODIFIED", datetime.now(timezone.utc))
|
|
344
|
+
|
|
345
|
+
# Save the updated journal
|
|
346
|
+
caldav_journal.data = ical.to_ical().decode("utf-8")
|
|
347
|
+
caldav_journal.save()
|
|
348
|
+
|
|
349
|
+
# Parse and return the updated journal
|
|
350
|
+
return self._parse_caldav_journal(
|
|
351
|
+
caldav_journal, calendar_uid, account_alias
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
except JournalNotFoundError:
|
|
355
|
+
raise
|
|
356
|
+
except EventCreationError:
|
|
357
|
+
raise
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.error(
|
|
360
|
+
f"Error updating journal '{journal_uid}': {e}",
|
|
361
|
+
extra={"request_id": request_id},
|
|
362
|
+
)
|
|
363
|
+
raise EventCreationError(
|
|
364
|
+
journal_uid,
|
|
365
|
+
f"Failed to update journal: {str(e)}",
|
|
366
|
+
request_id=request_id,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def delete_journal(
|
|
370
|
+
self,
|
|
371
|
+
calendar_uid: str,
|
|
372
|
+
journal_uid: str,
|
|
373
|
+
account_alias: Optional[str] = None,
|
|
374
|
+
request_id: Optional[str] = None,
|
|
375
|
+
) -> bool:
|
|
376
|
+
"""Delete a journal by UID - raises exceptions on failure"""
|
|
377
|
+
request_id = request_id or str(uuid.uuid4())
|
|
378
|
+
|
|
379
|
+
calendar = self.calendars.get_calendar(
|
|
380
|
+
calendar_uid, account_alias, request_id=request_id
|
|
381
|
+
)
|
|
382
|
+
if not calendar:
|
|
383
|
+
raise CalendarNotFoundError(
|
|
384
|
+
calendar_uid, account_alias, request_id=request_id
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
# Use utility function to find journal with automatic fallback
|
|
389
|
+
journal = get_item_with_fallback(
|
|
390
|
+
calendar, journal_uid, "journal", request_id=request_id
|
|
391
|
+
)
|
|
392
|
+
journal.delete()
|
|
393
|
+
logger.info(
|
|
394
|
+
f"Deleted journal '{journal_uid}'",
|
|
395
|
+
extra={"request_id": request_id},
|
|
396
|
+
)
|
|
397
|
+
return True
|
|
398
|
+
except ValueError:
|
|
399
|
+
# get_item_with_fallback raises ValueError when not found
|
|
400
|
+
raise JournalNotFoundError(journal_uid, calendar_uid, request_id=request_id)
|
|
401
|
+
|
|
402
|
+
except JournalNotFoundError:
|
|
403
|
+
raise
|
|
404
|
+
except caldav.lib.error.AuthorizationError as e:
|
|
405
|
+
logger.error(
|
|
406
|
+
f"Authorization error deleting journal '{journal_uid}': {e}",
|
|
407
|
+
extra={"request_id": request_id},
|
|
408
|
+
)
|
|
409
|
+
raise EventDeletionError(
|
|
410
|
+
journal_uid, "Authorization failed", request_id=request_id
|
|
411
|
+
)
|
|
412
|
+
except Exception as e:
|
|
413
|
+
logger.error(
|
|
414
|
+
f"Error deleting journal '{journal_uid}': {e}",
|
|
415
|
+
extra={"request_id": request_id},
|
|
416
|
+
)
|
|
417
|
+
raise EventDeletionError(journal_uid, str(e), request_id=request_id)
|
|
418
|
+
|
|
419
|
+
def _parse_caldav_journal(
|
|
420
|
+
self, caldav_event: CalDAVEvent, calendar_uid: str, account_alias: Optional[str]
|
|
421
|
+
) -> Optional[Journal]:
|
|
422
|
+
"""Parse CalDAV VJOURNAL to Journal model"""
|
|
423
|
+
try:
|
|
424
|
+
# Parse iCalendar data
|
|
425
|
+
ical = iCalendar.from_ical(caldav_event.data)
|
|
426
|
+
|
|
427
|
+
for component in ical.walk():
|
|
428
|
+
# Debug logging to see what components we find
|
|
429
|
+
logger.debug(f"Found component: {component.name}")
|
|
430
|
+
if component.name == "VJOURNAL":
|
|
431
|
+
# Parse date/time values
|
|
432
|
+
dtstart_dt = None
|
|
433
|
+
if component.get("dtstart"):
|
|
434
|
+
dtstart_dt = ical_to_datetime(component.get("dtstart"))
|
|
435
|
+
|
|
436
|
+
# Parse categories
|
|
437
|
+
categories = []
|
|
438
|
+
if component.get("categories"):
|
|
439
|
+
cat_value = component.get("categories")
|
|
440
|
+
if isinstance(cat_value, list):
|
|
441
|
+
categories = [str(cat) for cat in cat_value]
|
|
442
|
+
else:
|
|
443
|
+
categories = [str(cat_value)]
|
|
444
|
+
|
|
445
|
+
# Parse RELATED-TO properties
|
|
446
|
+
related_to = []
|
|
447
|
+
if component.get("related-to"):
|
|
448
|
+
related_prop = component.get("related-to")
|
|
449
|
+
if isinstance(related_prop, list):
|
|
450
|
+
related_to = [str(r) for r in related_prop]
|
|
451
|
+
else:
|
|
452
|
+
related_to = [str(related_prop)]
|
|
453
|
+
|
|
454
|
+
# Parse basic journal data
|
|
455
|
+
journal = Journal(
|
|
456
|
+
uid=str(component.get("uid", "")),
|
|
457
|
+
summary=str(component.get("summary", "No Title")),
|
|
458
|
+
description=(
|
|
459
|
+
str(component.get("description", ""))
|
|
460
|
+
if component.get("description")
|
|
461
|
+
else None
|
|
462
|
+
),
|
|
463
|
+
dtstart=dtstart_dt or datetime.now(timezone.utc),
|
|
464
|
+
categories=categories,
|
|
465
|
+
related_to=related_to,
|
|
466
|
+
calendar_uid=calendar_uid,
|
|
467
|
+
account_alias=account_alias
|
|
468
|
+
or self._get_default_account()
|
|
469
|
+
or "default",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
return journal
|
|
473
|
+
|
|
474
|
+
except Exception as e:
|
|
475
|
+
logger.error(f"Error parsing journal: {e}")
|
|
476
|
+
|
|
477
|
+
return None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared logging configuration for Chronos MCP
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def setup_logging():
|
|
10
|
+
"""Configure logging to stderr for all Chronos modules"""
|
|
11
|
+
# Only configure if not already configured
|
|
12
|
+
if not logging.getLogger().handlers:
|
|
13
|
+
logging.basicConfig(
|
|
14
|
+
level=logging.INFO,
|
|
15
|
+
stream=sys.stderr,
|
|
16
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
import inspect
|
|
20
|
+
|
|
21
|
+
frame = inspect.stack()[1]
|
|
22
|
+
module = inspect.getmodule(frame[0])
|
|
23
|
+
return logging.getLogger(module.__name__ if module else __name__)
|
chronos_mcp/models.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for Chronos MCP
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AccountStatus(str, Enum):
|
|
13
|
+
"""Account connection status"""
|
|
14
|
+
|
|
15
|
+
CONNECTED = "connected"
|
|
16
|
+
DISCONNECTED = "disconnected"
|
|
17
|
+
ERROR = "error"
|
|
18
|
+
UNKNOWN = "unknown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EventStatus(str, Enum):
|
|
22
|
+
"""Event status values"""
|
|
23
|
+
|
|
24
|
+
TENTATIVE = "TENTATIVE"
|
|
25
|
+
CONFIRMED = "CONFIRMED"
|
|
26
|
+
CANCELLED = "CANCELLED"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TaskStatus(str, Enum):
|
|
30
|
+
"""Task status values"""
|
|
31
|
+
|
|
32
|
+
NEEDS_ACTION = "NEEDS-ACTION"
|
|
33
|
+
IN_PROCESS = "IN-PROCESS"
|
|
34
|
+
COMPLETED = "COMPLETED"
|
|
35
|
+
CANCELLED = "CANCELLED"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AttendeeRole(str, Enum):
|
|
39
|
+
"""Attendee roles"""
|
|
40
|
+
|
|
41
|
+
CHAIR = "CHAIR"
|
|
42
|
+
REQ_PARTICIPANT = "REQ-PARTICIPANT"
|
|
43
|
+
OPT_PARTICIPANT = "OPT-PARTICIPANT"
|
|
44
|
+
NON_PARTICIPANT = "NON-PARTICIPANT"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AttendeeStatus(str, Enum):
|
|
48
|
+
"""Attendee participation status"""
|
|
49
|
+
|
|
50
|
+
NEEDS_ACTION = "NEEDS-ACTION"
|
|
51
|
+
ACCEPTED = "ACCEPTED"
|
|
52
|
+
DECLINED = "DECLINED"
|
|
53
|
+
TENTATIVE = "TENTATIVE"
|
|
54
|
+
DELEGATED = "DELEGATED"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Account(BaseModel):
|
|
58
|
+
"""CalDAV account configuration"""
|
|
59
|
+
|
|
60
|
+
alias: str = Field(..., description="Account alias/identifier")
|
|
61
|
+
url: HttpUrl = Field(..., description="CalDAV server URL")
|
|
62
|
+
username: str = Field(..., description="Username for authentication")
|
|
63
|
+
password: Optional[str] = Field(
|
|
64
|
+
None, description="Password (optional if using keyring)"
|
|
65
|
+
)
|
|
66
|
+
display_name: Optional[str] = Field(
|
|
67
|
+
None, description="Display name for the account"
|
|
68
|
+
)
|
|
69
|
+
status: AccountStatus = Field(
|
|
70
|
+
AccountStatus.UNKNOWN, description="Connection status"
|
|
71
|
+
)
|
|
72
|
+
last_sync: Optional[datetime] = Field(None, description="Last successful sync time")
|
|
73
|
+
|
|
74
|
+
@field_validator('password')
|
|
75
|
+
@classmethod
|
|
76
|
+
def validate_password_field(cls, v: Optional[str]) -> Optional[str]:
|
|
77
|
+
"""Validate password for security (defense-in-depth)"""
|
|
78
|
+
if v is not None and v != "":
|
|
79
|
+
from .validation import InputValidator
|
|
80
|
+
from .exceptions import ValidationError as ChronosValidationError
|
|
81
|
+
try:
|
|
82
|
+
# Validate to prevent injection attacks at model layer
|
|
83
|
+
return InputValidator.validate_text_field(v, "password", required=False)
|
|
84
|
+
except ChronosValidationError as e:
|
|
85
|
+
# Re-raise as Pydantic ValidationError for proper handling
|
|
86
|
+
from pydantic_core import PydanticCustomError
|
|
87
|
+
raise PydanticCustomError(
|
|
88
|
+
'password_validation',
|
|
89
|
+
'Password validation failed: {error}',
|
|
90
|
+
{'error': str(e)}
|
|
91
|
+
)
|
|
92
|
+
return v
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Calendar(BaseModel):
|
|
96
|
+
"""Calendar information"""
|
|
97
|
+
|
|
98
|
+
uid: str = Field(..., description="Calendar unique identifier")
|
|
99
|
+
name: str = Field(..., description="Calendar display name")
|
|
100
|
+
description: Optional[str] = Field(None, description="Calendar description")
|
|
101
|
+
color: Optional[str] = Field(None, description="Calendar color (hex)")
|
|
102
|
+
account_alias: str = Field(..., description="Associated account alias")
|
|
103
|
+
url: Optional[str] = Field(None, description="Calendar URL")
|
|
104
|
+
read_only: bool = Field(False, description="Whether calendar is read-only")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Attendee(BaseModel):
|
|
108
|
+
"""Event attendee"""
|
|
109
|
+
|
|
110
|
+
email: str = Field(..., description="Attendee email address")
|
|
111
|
+
name: Optional[str] = Field(None, description="Attendee display name")
|
|
112
|
+
role: AttendeeRole = Field(
|
|
113
|
+
AttendeeRole.REQ_PARTICIPANT, description="Attendee role"
|
|
114
|
+
)
|
|
115
|
+
status: AttendeeStatus = Field(
|
|
116
|
+
AttendeeStatus.NEEDS_ACTION, description="Participation status"
|
|
117
|
+
)
|
|
118
|
+
rsvp: bool = Field(True, description="Whether RSVP is requested")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Alarm(BaseModel):
|
|
122
|
+
"""Event reminder/alarm"""
|
|
123
|
+
|
|
124
|
+
trigger: str = Field(
|
|
125
|
+
..., description="Trigger time (e.g., '-PT15M' for 15 minutes before)"
|
|
126
|
+
)
|
|
127
|
+
action: str = Field("DISPLAY", description="Alarm action type")
|
|
128
|
+
description: Optional[str] = Field(None, description="Alarm description")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Event(BaseModel):
|
|
132
|
+
"""Calendar event"""
|
|
133
|
+
|
|
134
|
+
uid: str = Field(..., description="Event unique identifier")
|
|
135
|
+
summary: str = Field(..., description="Event title/summary")
|
|
136
|
+
description: Optional[str] = Field(None, description="Event description")
|
|
137
|
+
start: datetime = Field(..., description="Event start time")
|
|
138
|
+
end: datetime = Field(..., description="Event end time")
|
|
139
|
+
all_day: bool = Field(False, description="Whether this is an all-day event")
|
|
140
|
+
location: Optional[str] = Field(None, description="Event location")
|
|
141
|
+
status: EventStatus = Field(EventStatus.CONFIRMED, description="Event status")
|
|
142
|
+
attendees: List[Attendee] = Field(
|
|
143
|
+
default_factory=list, description="Event attendees"
|
|
144
|
+
)
|
|
145
|
+
alarms: List[Alarm] = Field(
|
|
146
|
+
default_factory=list, description="Event alarms/reminders"
|
|
147
|
+
)
|
|
148
|
+
recurrence_rule: Optional[str] = Field(
|
|
149
|
+
None, description="RRULE for recurring events"
|
|
150
|
+
)
|
|
151
|
+
recurrence_id: Optional[datetime] = Field(
|
|
152
|
+
None, description="Recurrence instance identifier"
|
|
153
|
+
)
|
|
154
|
+
calendar_uid: str = Field(..., description="Parent calendar UID")
|
|
155
|
+
account_alias: str = Field(..., description="Associated account alias")
|
|
156
|
+
categories: List[str] = Field(
|
|
157
|
+
default_factory=list, description="Event categories/tags"
|
|
158
|
+
)
|
|
159
|
+
url: Optional[str] = Field(None, description="Associated URL")
|
|
160
|
+
related_to: List[str] = Field(
|
|
161
|
+
default_factory=list, description="Related component UIDs"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Task(BaseModel):
|
|
166
|
+
"""Calendar task (VTODO)"""
|
|
167
|
+
|
|
168
|
+
uid: str = Field(..., description="Task unique identifier")
|
|
169
|
+
summary: str = Field(..., description="Task summary")
|
|
170
|
+
description: Optional[str] = Field(None, description="Task description")
|
|
171
|
+
due: Optional[datetime] = Field(None, description="Task due date")
|
|
172
|
+
completed: Optional[datetime] = Field(None, description="Task completion date")
|
|
173
|
+
priority: Optional[int] = Field(
|
|
174
|
+
None, description="Task priority (1-9, 1 is highest)"
|
|
175
|
+
)
|
|
176
|
+
status: TaskStatus = Field(TaskStatus.NEEDS_ACTION, description="Task status")
|
|
177
|
+
percent_complete: int = Field(0, description="Completion percentage (0-100)")
|
|
178
|
+
categories: List[str] = Field(
|
|
179
|
+
default_factory=list, description="Task categories/tags"
|
|
180
|
+
)
|
|
181
|
+
related_to: List[str] = Field(
|
|
182
|
+
default_factory=list, description="Related component UIDs"
|
|
183
|
+
)
|
|
184
|
+
calendar_uid: str = Field(..., description="Parent calendar UID")
|
|
185
|
+
account_alias: str = Field(..., description="Associated account alias")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Journal(BaseModel):
|
|
189
|
+
"""Calendar journal entry (VJOURNAL)"""
|
|
190
|
+
|
|
191
|
+
uid: str = Field(..., description="Journal unique identifier")
|
|
192
|
+
summary: str = Field(..., description="Journal title/summary")
|
|
193
|
+
description: Optional[str] = Field(None, description="Journal content")
|
|
194
|
+
dtstart: datetime = Field(..., description="Journal entry date/time")
|
|
195
|
+
categories: List[str] = Field(
|
|
196
|
+
default_factory=list, description="Journal categories/tags"
|
|
197
|
+
)
|
|
198
|
+
related_to: List[str] = Field(
|
|
199
|
+
default_factory=list, description="Related component UIDs"
|
|
200
|
+
)
|
|
201
|
+
calendar_uid: str = Field(..., description="Parent calendar UID")
|
|
202
|
+
account_alias: str = Field(..., description="Associated account alias")
|