Skip to content

Commit 2bcfd3d

Browse files
author
Chris Coutinho
committed
fix(calendar): Fix iCalendar date vs datetime format
1 parent 75235d6 commit 2bcfd3d

File tree

4 files changed

+412
-47
lines changed

4 files changed

+412
-47
lines changed

nextcloud_mcp_server/client/calendar.py

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -118,18 +118,27 @@ async def list_calendars(self) -> List[Dict[str, Any]]:
118118
async def get_calendar_events(
119119
self,
120120
calendar_name: str,
121-
start_date: str = "",
122-
end_date: str = "",
121+
start_datetime: Optional[dt.datetime] = None,
122+
end_datetime: Optional[dt.datetime] = None,
123123
limit: int = 50,
124124
) -> List[Dict[str, Any]]:
125125
"""List events in a calendar within date range."""
126126
calendar_path = f"{self._get_caldav_base_path()}/{calendar_name}/"
127127

128128
# Build time range filter if dates provided
129129
time_range_filter = ""
130-
if start_date or end_date:
131-
start_dt = start_date or "19700101T000000Z"
132-
end_dt = end_date or "20301231T235959Z"
130+
if start_datetime or end_datetime:
131+
# Convert datetime objects to CalDAV format (YYYYMMDDTHHMMSSZ)
132+
start_dt = (
133+
start_datetime.strftime("%Y%m%dT%H%M%SZ")
134+
if start_datetime
135+
else "19700101T000000Z"
136+
)
137+
end_dt = (
138+
end_datetime.strftime("%Y%m%dT%H%M%SZ")
139+
if end_datetime
140+
else "20301231T235959Z"
141+
)
133142
time_range_filter = f"""
134143
<c:time-range start="{start_dt}" end="{end_dt}"/>
135144
"""
@@ -504,8 +513,8 @@ def _extract_categories(self, categories_obj) -> str:
504513

505514
async def search_events_across_calendars(
506515
self,
507-
start_date: str = "",
508-
end_date: str = "",
516+
start_datetime: Optional[dt.datetime] = None,
517+
end_datetime: Optional[dt.datetime] = None,
509518
filters: Optional[Dict[str, Any]] = None,
510519
) -> List[Dict[str, Any]]:
511520
"""Search events across all calendars with advanced filtering."""
@@ -516,7 +525,7 @@ async def search_events_across_calendars(
516525
for calendar in calendars:
517526
try:
518527
events = await self.get_calendar_events(
519-
calendar["name"], start_date, end_date
528+
calendar["name"], start_datetime, end_datetime
520529
)
521530

522531
# Apply filters if provided
@@ -623,22 +632,21 @@ async def find_availability(
623632
self,
624633
duration_minutes: int,
625634
attendees: Optional[List[str]] = None,
626-
date_range_start: str = "",
627-
date_range_end: str = "",
635+
start_datetime: Optional[dt.datetime] = None,
636+
end_datetime: Optional[dt.datetime] = None,
628637
constraints: Optional[Dict[str, Any]] = None,
629638
) -> List[Dict[str, Any]]:
630639
"""Find available time slots for scheduling."""
631640
try:
632641
# Set default date range if not provided
633-
if not date_range_start:
634-
date_range_start = dt.datetime.now().strftime("%Y-%m-%d")
635-
if not date_range_end:
636-
end_date = dt.datetime.now() + dt.timedelta(days=7)
637-
date_range_end = end_date.strftime("%Y-%m-%d")
642+
if not start_datetime:
643+
start_datetime = dt.datetime.now()
644+
if not end_datetime:
645+
end_datetime = dt.datetime.now() + dt.timedelta(days=7)
638646

639647
# Get all events in the date range
640648
busy_events = await self.search_events_across_calendars(
641-
start_date=date_range_start, end_date=date_range_end
649+
start_datetime=start_datetime, end_datetime=end_datetime
642650
)
643651

644652
# Filter events for relevant attendees if specified
@@ -662,8 +670,8 @@ async def find_availability(
662670
available_slots = self._generate_available_slots(
663671
busy_events,
664672
duration_minutes,
665-
date_range_start,
666-
date_range_end,
673+
start_datetime,
674+
end_datetime,
667675
business_hours_only,
668676
exclude_weekends,
669677
preferred_times,
@@ -679,8 +687,8 @@ def _generate_available_slots(
679687
self,
680688
busy_events: List[Dict[str, Any]],
681689
duration_minutes: int,
682-
start_date: str,
683-
end_date: str,
690+
start_datetime: dt.datetime,
691+
end_datetime: dt.datetime,
684692
business_hours_only: bool,
685693
exclude_weekends: bool,
686694
preferred_times: List[str],
@@ -689,8 +697,12 @@ def _generate_available_slots(
689697
available_slots = []
690698

691699
try:
692-
current_date = dt.datetime.fromisoformat(start_date)
693-
end_date_dt = dt.datetime.fromisoformat(end_date)
700+
current_date = start_datetime.replace(
701+
hour=0, minute=0, second=0, microsecond=0
702+
)
703+
end_date_dt = end_datetime.replace(
704+
hour=23, minute=59, second=59, microsecond=999999
705+
)
694706

695707
while current_date <= end_date_dt:
696708
# Skip weekends if requested
@@ -819,10 +831,20 @@ async def bulk_update_events(
819831
) -> Dict[str, Any]:
820832
"""Bulk update events matching filter criteria."""
821833
try:
834+
# Convert string dates to datetime objects if present
835+
start_datetime = None
836+
end_datetime = None
837+
if "start_date" in filter_criteria and filter_criteria["start_date"]:
838+
start_datetime = dt.datetime.fromisoformat(
839+
filter_criteria["start_date"]
840+
)
841+
if "end_date" in filter_criteria and filter_criteria["end_date"]:
842+
end_datetime = dt.datetime.fromisoformat(filter_criteria["end_date"])
843+
822844
# Find events matching criteria
823845
events = await self.search_events_across_calendars(
824-
start_date=filter_criteria.get("start_date", ""),
825-
end_date=filter_criteria.get("end_date", ""),
846+
start_datetime=start_datetime,
847+
end_datetime=end_datetime,
826848
filters=filter_criteria,
827849
)
828850

nextcloud_mcp_server/server/calendar.py

Lines changed: 85 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,33 @@ async def nc_calendar_list_events(
126126
"""
127127
client: NextcloudClient = ctx.request_context.lifespan_context.client
128128

129+
# Convert YYYY-MM-DD format dates to datetime objects
130+
start_datetime = None
131+
end_datetime = None
132+
133+
if start_date:
134+
try:
135+
start_datetime = dt.datetime.strptime(start_date, "%Y-%m-%d")
136+
except ValueError:
137+
# If parsing fails, try to parse as ISO format
138+
try:
139+
start_datetime = dt.datetime.fromisoformat(start_date)
140+
except ValueError:
141+
logger.warning(f"Invalid start_date format: {start_date}")
142+
143+
if end_date:
144+
try:
145+
# For end date, set to end of day (23:59:59)
146+
end_datetime = dt.datetime.strptime(end_date, "%Y-%m-%d").replace(
147+
hour=23, minute=59, second=59
148+
)
149+
except ValueError:
150+
# If parsing fails, try to parse as ISO format
151+
try:
152+
end_datetime = dt.datetime.fromisoformat(end_date)
153+
except ValueError:
154+
logger.warning(f"Invalid end_date format: {end_date}")
155+
129156
# Build filters dictionary
130157
filters = {}
131158
if min_attendees is not None:
@@ -144,17 +171,17 @@ async def nc_calendar_list_events(
144171
if search_all_calendars:
145172
# Search across all calendars with filters
146173
events = await client.calendar.search_events_across_calendars(
147-
start_date=start_date,
148-
end_date=end_date,
174+
start_datetime=start_datetime,
175+
end_datetime=end_datetime,
149176
filters=filters if filters else None,
150177
)
151178
return events[:limit]
152179
else:
153180
# Search in specific calendar
154181
events = await client.calendar.get_calendar_events(
155182
calendar_name=calendar_name,
156-
start_date=start_date,
157-
end_date=end_date,
183+
start_datetime=start_datetime,
184+
end_datetime=end_datetime,
158185
limit=limit,
159186
)
160187

@@ -302,7 +329,6 @@ async def nc_calendar_create_meeting(
302329
start_datetime = f"{date}T{time}:00"
303330

304331
# Calculate end_datetime
305-
306332
start_dt = dt.datetime.fromisoformat(start_datetime)
307333
end_dt = start_dt + dt.timedelta(minutes=duration_minutes)
308334
end_datetime = end_dt.isoformat()
@@ -334,17 +360,14 @@ async def nc_calendar_get_upcoming_events(
334360
client: NextcloudClient = ctx.request_context.lifespan_context.client
335361

336362
now = dt.datetime.now()
337-
end_date = now + dt.timedelta(days=days_ahead)
338-
339-
start_date_str = now.strftime("%Y%m%dT%H%M%SZ")
340-
end_date_str = end_date.strftime("%Y%m%dT%H%M%SZ")
363+
end_datetime = now + dt.timedelta(days=days_ahead)
341364

342365
if calendar_name:
343366
# Get events from specific calendar
344367
return await client.calendar.get_calendar_events(
345368
calendar_name=calendar_name,
346-
start_date=start_date_str,
347-
end_date=end_date_str,
369+
start_datetime=now,
370+
end_datetime=end_datetime,
348371
limit=limit,
349372
)
350373
else:
@@ -356,8 +379,8 @@ async def nc_calendar_get_upcoming_events(
356379
try:
357380
events = await client.calendar.get_calendar_events(
358381
calendar_name=calendar["name"],
359-
start_date=start_date_str,
360-
end_date=end_date_str,
382+
start_datetime=now,
383+
end_datetime=end_datetime,
361384
limit=limit,
362385
)
363386
# Add calendar info to each event
@@ -421,6 +444,24 @@ async def nc_calendar_find_availability(
421444
if time_range.strip()
422445
]
423446

447+
# Convert date strings to datetime objects
448+
start_datetime = None
449+
end_datetime = None
450+
451+
if date_range_start:
452+
try:
453+
start_datetime = dt.datetime.strptime(date_range_start, "%Y-%m-%d")
454+
except ValueError:
455+
logger.warning(f"Invalid date_range_start format: {date_range_start}")
456+
457+
if date_range_end:
458+
try:
459+
end_datetime = dt.datetime.strptime(date_range_end, "%Y-%m-%d").replace(
460+
hour=23, minute=59, second=59
461+
)
462+
except ValueError:
463+
logger.warning(f"Invalid date_range_end format: {date_range_end}")
464+
424465
# Build constraints
425466
constraints = {
426467
"business_hours_only": business_hours_only,
@@ -431,8 +472,8 @@ async def nc_calendar_find_availability(
431472
return await client.calendar.find_availability(
432473
duration_minutes=duration_minutes,
433474
attendees=attendee_list,
434-
date_range_start=date_range_start,
435-
date_range_end=date_range_end,
475+
start_datetime=start_datetime,
476+
end_datetime=end_datetime,
436477
constraints=constraints,
437478
)
438479

@@ -491,6 +532,24 @@ async def nc_calendar_bulk_operations(
491532
if operation not in ["update", "delete", "move"]:
492533
raise ValueError("Operation must be 'update', 'delete', or 'move'")
493534

535+
# Convert date strings to datetime objects
536+
start_datetime = None
537+
end_datetime = None
538+
539+
if start_date:
540+
try:
541+
start_datetime = dt.datetime.strptime(start_date, "%Y-%m-%d")
542+
except ValueError:
543+
logger.warning(f"Invalid start_date format: {start_date}")
544+
545+
if end_date:
546+
try:
547+
end_datetime = dt.datetime.strptime(end_date, "%Y-%m-%d").replace(
548+
hour=23, minute=59, second=59
549+
)
550+
except ValueError:
551+
logger.warning(f"Invalid end_date format: {end_date}")
552+
494553
# Build filter criteria
495554
filter_criteria = {}
496555
if title_contains is not None:
@@ -503,6 +562,7 @@ async def nc_calendar_bulk_operations(
503562
filter_criteria["status"] = status
504563
if location_contains is not None:
505564
filter_criteria["location_contains"] = location_contains
565+
# Add datetime strings for client compatibility
506566
if start_date:
507567
filter_criteria["start_date"] = start_date
508568
if end_date:
@@ -513,16 +573,18 @@ async def nc_calendar_bulk_operations(
513573
if calendar_name:
514574
events = await client.calendar.get_calendar_events(
515575
calendar_name=calendar_name,
516-
start_date=start_date,
517-
end_date=end_date,
576+
start_datetime=start_datetime,
577+
end_datetime=end_datetime,
518578
)
519579
if filter_criteria:
520580
events = client.calendar._apply_event_filters(
521581
events, filter_criteria
522582
)
523583
else:
524584
events = await client.calendar.search_events_across_calendars(
525-
start_date=start_date, end_date=end_date, filters=filter_criteria
585+
start_datetime=start_datetime,
586+
end_datetime=end_datetime,
587+
filters=filter_criteria,
526588
)
527589

528590
deleted_count = 0
@@ -592,16 +654,18 @@ async def nc_calendar_bulk_operations(
592654
if calendar_name:
593655
events = await client.calendar.get_calendar_events(
594656
calendar_name=calendar_name,
595-
start_date=start_date,
596-
end_date=end_date,
657+
start_datetime=start_datetime,
658+
end_datetime=end_datetime,
597659
)
598660
if filter_criteria:
599661
events = client.calendar._apply_event_filters(
600662
events, filter_criteria
601663
)
602664
else:
603665
events = await client.calendar.search_events_across_calendars(
604-
start_date=start_date, end_date=end_date, filters=filter_criteria
666+
start_datetime=start_datetime,
667+
end_datetime=end_datetime,
668+
filters=filter_criteria,
605669
)
606670

607671
moved_count = 0

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ asyncio_mode = "auto"
1919
asyncio_default_test_loop_scope = "session"
2020
asyncio_default_fixture_loop_scope = "session"
2121
log_cli = 1
22-
log_cli_level = "WARN"
23-
log_level = "WARN"
22+
log_cli_level = "INFO"
23+
log_level = "INFO"
2424
markers = [
2525
"integration: marks tests as slow (deselect with '-m \"not slow\"')"
2626
]

0 commit comments

Comments
 (0)