Skip to main content
Glama
cbcoutinho

Nextcloud MCP Server

by cbcoutinho
calendar.py45.5 kB
"""CalDAV client for Nextcloud calendar and task operations using caldav library.""" import datetime as dt import logging import uuid from typing import Any, Dict, List, Optional import anyio from caldav.async_collection import AsyncCalendar from caldav.async_davclient import AsyncDAVClient from httpx import Auth from icalendar import Alarm, Calendar, vRecur from icalendar import Event as ICalEvent from icalendar import Todo as ICalTodo logger = logging.getLogger(__name__) class CalendarClient: """Client for Nextcloud CalDAV calendar and task operations.""" def __init__(self, base_url: str, username: str, auth: Auth | None = None): """Initialize CalendarClient with AsyncDAVClient. Args: base_url: Nextcloud base URL username: Nextcloud username auth: httpx.Auth object (BasicAuth or BearerAuth) """ self.username = username self.base_url = base_url # AsyncDAVClient needs the full base URL for proper URL construction self._dav_client = AsyncDAVClient( url=f"{base_url}/remote.php/dav/", username=username, auth=auth, ) self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/" def _get_calendar_url(self, calendar_name: str) -> str: """Get the full URL for a calendar.""" return f"{self._calendar_home_url}{calendar_name}/" def _get_calendar(self, calendar_name: str) -> AsyncCalendar: """Get an AsyncCalendar object for the given calendar name.""" calendar_url = self._get_calendar_url(calendar_name) return AsyncCalendar( client=self._dav_client, url=calendar_url, name=calendar_name ) async def close(self): """Close the DAV client connection.""" await self._dav_client.close() async def _wait_for_calendar_propagation( self, calendar_name: str, max_attempts: int = 40, initial_delay_ms: int = 100 ) -> None: """Wait for calendar to propagate through Nextcloud's DAV backend. After MKCALENDAR succeeds (201), the calendar may not be immediately queryable due to Nextcloud's internal caching/indexing. This polls until it appears. Args: calendar_name: Name of the calendar to wait for max_attempts: Maximum polling attempts (default: 40) initial_delay_ms: Initial delay between attempts in ms (default: 100ms) """ logger.info(f"Waiting for calendar '{calendar_name}' to propagate...") delay_ms = initial_delay_ms for attempt in range(max_attempts): try: logger.debug( f"Attempt {attempt + 1}/{max_attempts} to find calendar '{calendar_name}'..." ) calendars = await self.list_calendars() if any(cal["name"] == calendar_name for cal in calendars): logger.info( f"Calendar '{calendar_name}' became available after {attempt + 1} attempts" ) return except Exception as e: logger.warning( f"Attempt {attempt + 1}/{max_attempts} to verify calendar '{calendar_name}' failed: {e}" ) if attempt < max_attempts - 1: await anyio.sleep(delay_ms / 1000.0) # Exponential backoff: double delay up to 2 seconds max delay_ms = min(delay_ms * 2, 2000) logger.error( f"Calendar '{calendar_name}' did not become available after {max_attempts} attempts." ) # ============= Calendar Operations ============= async def list_calendars(self) -> List[Dict[str, Any]]: """List all available calendars for the user.""" # Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color. # caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses # Apple iCal namespace which Nextcloud doesn't recognize. from lxml import etree # type: ignore[import-untyped] propfind_body = """<?xml version="1.0" encoding="utf-8"?> <d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav"> <d:prop> <d:displayname/> <d:resourcetype/> <cs:getctag/> <c:calendar-description/> <cs:calendar-color/> </d:prop> </d:propfind>""" response = await self._dav_client.propfind( self._calendar_home_url, props=propfind_body, depth=1 ) result = [] # Parse XML response tree = etree.fromstring(response.raw.encode("utf-8")) ns = { "d": "DAV:", "cs": "http://calendarserver.org/ns/", "c": "urn:ietf:params:xml:ns:caldav", } for response_elem in tree.findall(".//d:response", ns): # Check if this is a calendar (has resourcetype/calendar) resourcetype = response_elem.find(".//d:resourcetype", ns) if ( resourcetype is not None and resourcetype.find(".//c:calendar", ns) is not None ): href = response_elem.find("./d:href", ns) if href is not None and href.text: calendar_url = href.text # Extract calendar name from URL calendar_name = calendar_url.rstrip("/").split("/")[-1] # Skip if this is the calendar home itself if calendar_url.rstrip("/") == self._calendar_home_url.rstrip("/"): continue display_name_elem = response_elem.find(".//d:displayname", ns) display_name = ( display_name_elem.text if display_name_elem is not None and display_name_elem.text else calendar_name ) description_elem = response_elem.find( ".//c:calendar-description", ns ) description = ( description_elem.text if description_elem is not None and description_elem.text else "" ) color_elem = response_elem.find(".//cs:calendar-color", ns) color = ( color_elem.text if color_elem is not None and color_elem.text else "#1976D2" ) result.append( { "name": calendar_name, "display_name": display_name, "description": description, "color": color, "href": calendar_url, } ) logger.debug(f"Found {len(result)} calendars") return result async def create_calendar( self, calendar_name: str, display_name: str = "", description: str = "", color: str = "#1976D2", ) -> Dict[str, Any]: """Create a new calendar with retry on 429 errors.""" # Use custom MKCALENDAR XML instead of caldav library's make_calendar() due to: # 1. Missing CalendarServer namespace (cs:) in caldav's nsmap # 2. caldav's CalendarColor uses Apple iCal namespace, not cs:calendar-color # 3. make_calendar() doesn't support calendar-description or calendar-color params calendar_url = ( f"{self.base_url}/remote.php/dav/calendars/{self.username}/{calendar_name}/" ) mkcalendar_body = f"""<?xml version="1.0" encoding="utf-8"?> <mkcalendar xmlns="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"> <d:set> <d:prop> <d:displayname>{display_name or calendar_name}</d:displayname> <cs:calendar-color>{color}</cs:calendar-color> <caldav:calendar-description xmlns:caldav="urn:ietf:params:xml:ns:caldav">{description}</caldav:calendar-description> <caldav:supported-calendar-component-set xmlns:caldav="urn:ietf:params:xml:ns:caldav"> <caldav:comp name="VEVENT"/> <caldav:comp name="VTODO"/> </caldav:supported-calendar-component-set> </d:prop> </d:set> </mkcalendar>""" # Create calendar via MKCALENDAR request response = await self._dav_client.mkcalendar(calendar_url, mkcalendar_body) if response.status != 201: raise RuntimeError( f"Failed to create calendar '{calendar_name}': HTTP {response.status}" ) logger.debug(f"Created calendar: {calendar_name}") # Wait for calendar to be queryable (Nextcloud eventual consistency) await self._wait_for_calendar_propagation(calendar_name) return { "name": calendar_name, "display_name": display_name or calendar_name, "description": description, "color": color, "status_code": 201, } async def delete_calendar(self, calendar_name: str) -> Dict[str, Any]: """Delete a calendar.""" # Use absolute URL for deletion calendar_url = ( f"{self.base_url}/remote.php/dav/calendars/{self.username}/{calendar_name}/" ) await self._dav_client.delete(calendar_url) logger.debug(f"Deleted calendar: {calendar_name}") return {"status_code": 204} # ============= Event Operations ============= async def get_calendar_events( self, calendar_name: str, start_datetime: Optional[dt.datetime] = None, end_datetime: Optional[dt.datetime] = None, limit: int = 50, ) -> List[Dict[str, Any]]: """List events in a calendar within date range.""" calendar = self._get_calendar(calendar_name) # Get all events using caldav library (now with proper filter) events = await calendar.events() result = [] for event in events: await event.load(only_if_unloaded=True) if event.data: event_dict = self._parse_ical_event(event.data) if event_dict: event_dict["href"] = str(event.url) event_dict["etag"] = "" result.append(event_dict) if len(result) >= limit: break logger.debug(f"Found {len(result)} events") return result async def create_event( self, calendar_name: str, event_data: Dict[str, Any] ) -> Dict[str, Any]: """Create a new calendar event.""" calendar = self._get_calendar(calendar_name) event_uid = str(uuid.uuid4()) ical_content = self._create_ical_event(event_data, event_uid) # save_event returns (event, response) tuple event, response = await calendar.save_event(ical=ical_content) if response.status not in [201, 204]: raise RuntimeError( f"Failed to create event {event_uid}: HTTP {response.status}" ) logger.debug(f"Created event {event_uid}") return { "uid": event_uid, "href": str(event.url), "etag": "", "status_code": 201, } async def update_event( self, calendar_name: str, event_uid: str, event_data: Dict[str, Any], etag: str = "", ) -> Dict[str, Any]: """Update an existing calendar event.""" calendar = self._get_calendar(calendar_name) # Find the event by UID using caldav library event = await calendar.event_by_uid(event_uid) await event.load(only_if_unloaded=True) # Merge updates into existing iCal data updated_ical = self._merge_ical_properties(event.data, event_data, event_uid) # type: ignore[arg-type] event.data = updated_ical # type: ignore[misc] await event.save() logger.debug(f"Updated event {event_uid}") return { "uid": event_uid, "href": str(event.url), "etag": "", "status_code": 200, } async def delete_event(self, calendar_name: str, event_uid: str) -> Dict[str, Any]: """Delete a calendar event.""" calendar = self._get_calendar(calendar_name) try: event = await calendar.event_by_uid(event_uid) await event.delete() logger.debug(f"Deleted event {event_uid}") return {"status_code": 204} except Exception as e: logger.debug(f"Event {event_uid} not found: {e}") return {"status_code": 404} async def get_event( self, calendar_name: str, event_uid: str ) -> tuple[Dict[str, Any], str]: """Get detailed information about a specific event.""" calendar = self._get_calendar(calendar_name) event = await calendar.event_by_uid(event_uid) await event.load(only_if_unloaded=True) event_data = self._parse_ical_event(event.data) if event.data else None # type: ignore[arg-type] if not event_data: raise ValueError(f"Failed to parse event data for {event_uid}") event_data["href"] = str(event.url) event_data["etag"] = "" logger.debug(f"Retrieved event {event_uid}") return event_data, "" async def search_events_across_calendars( self, start_datetime: Optional[dt.datetime] = None, end_datetime: Optional[dt.datetime] = None, filters: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Search events across all calendars with advanced filtering.""" try: calendars = await self.list_calendars() all_events = [] for calendar in calendars: try: events = await self.get_calendar_events( calendar["name"], start_datetime, end_datetime ) # Apply filters if provided if filters: events = self._apply_event_filters(events, filters) # Add calendar info to each event for event in events: event["calendar_name"] = calendar["name"] event["calendar_display_name"] = calendar.get( "display_name", calendar["name"] ) all_events.extend(events) except Exception as e: logger.warning( f"Error getting events from calendar {calendar['name']}: {e}" ) continue return all_events except Exception as e: logger.error(f"Error searching events across calendars: {e}") raise # ============= Todo/Task Operations (NEW) ============= async def list_todos( self, calendar_name: str, filters: Optional[Dict[str, Any]] = None ) -> List[Dict[str, Any]]: """List todos/tasks in a calendar.""" calendar = self._get_calendar(calendar_name) # Get all todos using caldav library (now with proper filter) todos = await calendar.todos() result = [] for todo in todos: # Only load if data not already present from REPORT response # This avoids 404 errors for virtual calendars (e.g., Deck boards) await todo.load(only_if_unloaded=True) if todo.data: todo_dict = self._parse_ical_todo(todo.data) # type: ignore[arg-type] else: continue if todo_dict: todo_dict["href"] = str(todo.url) todo_dict["etag"] = "" # Apply filters if provided if not filters or self._todo_matches_filters(todo_dict, filters): result.append(todo_dict) logger.debug(f"Found {len(result)} todos") return result async def create_todo( self, calendar_name: str, todo_data: Dict[str, Any] ) -> Dict[str, Any]: """Create a new todo/task.""" calendar = self._get_calendar(calendar_name) todo_uid = str(uuid.uuid4()) ical_content = self._create_ical_todo(todo_data, todo_uid) # save_todo returns (todo, response) tuple todo, response = await calendar.save_todo(ical=ical_content) if response.status not in [201, 204]: raise RuntimeError( f"Failed to create todo {todo_uid}: HTTP {response.status}" ) logger.debug(f"Created todo {todo_uid}") return { "uid": todo_uid, "href": str(todo.url), "etag": "", "status_code": 201, } async def update_todo( self, calendar_name: str, todo_uid: str, todo_data: Dict[str, Any], etag: str = "", ) -> Dict[str, Any]: """Update an existing todo/task.""" calendar = self._get_calendar(calendar_name) try: # Find the todo by UID todo = await calendar.todo_by_uid(todo_uid) await todo.load(only_if_unloaded=True) logger.debug( f"Loaded todo {todo_uid}, current data length: {len(todo.data)}" # type: ignore ) # Merge updates into existing iCal data updated_ical = self._merge_ical_todo_properties( todo.data, # type: ignore[arg-type] todo_data, todo_uid, ) logger.debug(f"Merged iCal data length: {len(updated_ical)}") logger.debug(f"Updated iCal content:\n{updated_ical}") todo.data = updated_ical save_result = await todo.save() logger.debug(f"Save result: {save_result}") logger.debug(f"Updated todo {todo_uid}") return { "uid": todo_uid, "href": str(todo.url), "etag": "", "status_code": 200, } except Exception as e: logger.error(f"Error updating todo {todo_uid}: {e}", exc_info=True) raise async def delete_todo(self, calendar_name: str, todo_uid: str) -> Dict[str, Any]: """Delete a todo/task.""" calendar = self._get_calendar(calendar_name) try: todo = await calendar.todo_by_uid(todo_uid) await todo.delete() logger.debug(f"Deleted todo {todo_uid}") return {"status_code": 204} except Exception as e: logger.debug(f"Todo {todo_uid} not found: {e}") return {"status_code": 404} async def search_todos_across_calendars( self, filters: Optional[Dict[str, Any]] = None ) -> List[Dict[str, Any]]: """Search todos across all calendars.""" try: calendars = await self.list_calendars() all_todos = [] for calendar in calendars: try: todos = await self.list_todos(calendar["name"], filters) # Add calendar info to each todo for todo in todos: todo["calendar_name"] = calendar["name"] todo["calendar_display_name"] = calendar.get( "display_name", calendar["name"] ) all_todos.extend(todos) except Exception as e: logger.warning( f"Error getting todos from calendar {calendar['name']}: {e}" ) continue return all_todos except Exception as e: logger.error(f"Error searching todos across calendars: {e}") raise # ============= Helper Methods - Event iCalendar ============= def _create_ical_event(self, event_data: Dict[str, Any], event_uid: str) -> str: """Create iCalendar content from event data.""" cal = Calendar() cal.add("prodid", "-//Nextcloud MCP Server//EN") cal.add("version", "2.0") event = ICalEvent() event.add("uid", event_uid) event.add("summary", event_data.get("title", "")) event.add("description", event_data.get("description", "")) event.add("location", event_data.get("location", "")) # Handle dates/times start_str = event_data.get("start_datetime", "") end_str = event_data.get("end_datetime", "") all_day = event_data.get("all_day", False) if start_str: if all_day: start_date = dt.datetime.fromisoformat(start_str.split("T")[0]).date() event.add("dtstart", start_date) if end_str: end_date = dt.datetime.fromisoformat(end_str.split("T")[0]).date() event.add("dtend", end_date) else: start_dt = dt.datetime.fromisoformat(start_str.replace("Z", "+00:00")) event.add("dtstart", start_dt) if end_str: end_dt = dt.datetime.fromisoformat(end_str.replace("Z", "+00:00")) event.add("dtend", end_dt) # Add categories categories = event_data.get("categories", "") if categories: event.add("categories", categories.split(",")) # Add priority and status priority = event_data.get("priority", 5) event.add("priority", priority) status = event_data.get("status", "CONFIRMED") event.add("status", status) # Add privacy classification privacy = event_data.get("privacy", "PUBLIC") event.add("class", privacy) # Add URL url = event_data.get("url", "") if url: event.add("url", url) # Handle recurrence recurring = event_data.get("recurring", False) if recurring: recurrence_rule = event_data.get("recurrence_rule", "") if recurrence_rule: event.add("rrule", vRecur.from_ical(recurrence_rule)) # Add alarms/reminders reminder_minutes = event_data.get("reminder_minutes", 0) if reminder_minutes > 0: alarm = Alarm() alarm.add("action", "DISPLAY") alarm.add("description", "Event reminder") alarm.add("trigger", dt.timedelta(minutes=-reminder_minutes)) event.add_component(alarm) # Add attendees attendees = event_data.get("attendees", "") if attendees: for email in attendees.split(","): if email.strip(): event.add("attendee", f"mailto:{email.strip()}") # Add timestamps now = dt.datetime.now(dt.UTC) event.add("created", now) event.add("dtstamp", now) event.add("last-modified", now) cal.add_component(event) return cal.to_ical().decode("utf-8") def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]: """Parse iCalendar text and extract event data.""" try: cal = Calendar.from_ical(ical_text) for component in cal.walk(): if component.name == "VEVENT": event_data = { "uid": str(component.get("uid", "")), "title": str(component.get("summary", "")), "description": str(component.get("description", "")), "location": str(component.get("location", "")), "status": str(component.get("status", "CONFIRMED")), "priority": int(component.get("priority", 5)), "privacy": str(component.get("class", "PUBLIC")), "url": str(component.get("url", "")), } # Handle dates dtstart = component.get("dtstart") if dtstart: if isinstance(dtstart.dt, dt.date) and not isinstance( dtstart.dt, dt.datetime ): event_data["start_datetime"] = dtstart.dt.isoformat() event_data["all_day"] = True else: event_data["start_datetime"] = dtstart.dt.isoformat() event_data["all_day"] = False dtend = component.get("dtend") if dtend: if isinstance(dtend.dt, dt.date) and not isinstance( dtend.dt, dt.datetime ): event_data["end_datetime"] = dtend.dt.isoformat() else: event_data["end_datetime"] = dtend.dt.isoformat() # Handle categories categories = component.get("categories") if categories: event_data["categories"] = self._extract_categories(categories) # Handle recurrence rrule = component.get("rrule") if rrule: event_data["recurring"] = True event_data["recurrence_rule"] = str(rrule) # Handle attendees attendees = [] for attendee in component.get("attendee", []): if isinstance(attendee, list): attendees.extend( str(a).replace("mailto:", "") for a in attendee ) else: attendees.append(str(attendee).replace("mailto:", "")) if attendees: event_data["attendees"] = ",".join(attendees) return event_data return None except Exception as e: logger.error(f"Error parsing iCalendar event: {e}") return None def _merge_ical_properties( self, raw_ical: str, event_data: Dict[str, Any], event_uid: str ) -> str: """Merge new event data into existing raw iCal while preserving all properties.""" try: cal = Calendar.from_ical(raw_ical) for component in cal.walk(): if component.name == "VEVENT": # Update only provided properties if "title" in event_data: component["SUMMARY"] = event_data["title"] if "description" in event_data: component["DESCRIPTION"] = event_data["description"] if "location" in event_data: component["LOCATION"] = event_data["location"] if "status" in event_data: component["STATUS"] = event_data["status"].upper() if "priority" in event_data: component["PRIORITY"] = event_data["priority"] if "privacy" in event_data: component["CLASS"] = event_data["privacy"].upper() if "url" in event_data: component["URL"] = event_data["url"] # Handle dates if "start_datetime" in event_data: start_str = event_data["start_datetime"] all_day = event_data.get("all_day", False) if all_day: start_date = dt.datetime.fromisoformat( start_str.split("T")[0] ).date() component["DTSTART"] = start_date else: start_dt = dt.datetime.fromisoformat( start_str.replace("Z", "+00:00") ) component["DTSTART"] = start_dt if "end_datetime" in event_data: end_str = event_data["end_datetime"] all_day = event_data.get("all_day", False) if all_day: end_date = dt.datetime.fromisoformat( end_str.split("T")[0] ).date() component["DTEND"] = end_date else: end_dt = dt.datetime.fromisoformat( end_str.replace("Z", "+00:00") ) component["DTEND"] = end_dt # Update timestamps from icalendar import vDDDTypes now = dt.datetime.now(dt.UTC) component["LAST-MODIFIED"] = vDDDTypes(now) component["DTSTAMP"] = vDDDTypes(now) break return cal.to_ical().decode("utf-8") except Exception as e: logger.error(f"Error merging iCal properties: {e}") return self._create_ical_event(event_data, event_uid) # ============= Helper Methods - Todo iCalendar ============= def _ensure_timezone_aware(self, datetime_str: str) -> dt.datetime: """Parse datetime string and ensure it's timezone-aware. If the datetime string doesn't include timezone info, interpret it as UTC. This ensures RFC 5545 compliance for CalDAV/iCalendar properties. Args: datetime_str: ISO format datetime string (e.g., "2025-10-19T14:30:00" or "2025-10-19T14:30:00Z") Returns: Timezone-aware datetime object """ # Replace 'Z' with '+00:00' for consistent parsing datetime_str = datetime_str.replace("Z", "+00:00") # Parse the datetime parsed_dt = dt.datetime.fromisoformat(datetime_str) # If timezone-naive, assume UTC if parsed_dt.tzinfo is None: parsed_dt = parsed_dt.replace(tzinfo=dt.UTC) return parsed_dt def _create_ical_todo(self, todo_data: Dict[str, Any], todo_uid: str) -> str: """Create iCalendar VTODO content from todo data.""" cal = Calendar() cal.add("prodid", "-//Nextcloud MCP Server//EN") cal.add("version", "2.0") todo = ICalTodo() todo.add("uid", todo_uid) todo.add("summary", todo_data.get("summary", "")) todo.add("description", todo_data.get("description", "")) # Status status = todo_data.get("status", "NEEDS-ACTION").upper() todo.add("status", status) # Priority (0-9, 0=undefined) priority = todo_data.get("priority", 0) todo.add("priority", priority) # Percent complete percent = todo_data.get("percent_complete", 0) todo.add("percent-complete", percent) # Due date due = todo_data.get("due", "") if due: from icalendar import vDDDTypes due_dt = self._ensure_timezone_aware(due) todo.add("due", vDDDTypes(due_dt)) # Start date dtstart = todo_data.get("dtstart", "") if dtstart: from icalendar import vDDDTypes start_dt = self._ensure_timezone_aware(dtstart) todo.add("dtstart", vDDDTypes(start_dt)) # Completed timestamp completed = todo_data.get("completed", "") if completed: from icalendar import vDDDTypes completed_dt = self._ensure_timezone_aware(completed) todo.add("completed", vDDDTypes(completed_dt)) # Categories categories = todo_data.get("categories", "") if categories: todo.add("categories", categories.split(",")) # Add timestamps now = dt.datetime.now(dt.UTC) todo.add("created", now) todo.add("dtstamp", now) todo.add("last-modified", now) cal.add_component(todo) return cal.to_ical().decode("utf-8") def _parse_ical_todo(self, ical_text: str) -> Optional[Dict[str, Any]]: """Parse iCalendar text and extract todo data.""" try: cal = Calendar.from_ical(ical_text) for component in cal.walk(): if component.name == "VTODO": todo_data = { "uid": str(component.get("uid", "")), "summary": str(component.get("summary", "")), "description": str(component.get("description", "")), "status": str(component.get("status", "NEEDS-ACTION")), "priority": int(component.get("priority", 0)), "percent_complete": int(component.get("percent-complete", 0)), } # Handle due date due = component.get("due") if due: todo_data["due"] = due.dt.isoformat() # Handle start date dtstart = component.get("dtstart") if dtstart: todo_data["dtstart"] = dtstart.dt.isoformat() # Handle completed date completed = component.get("completed") if completed: todo_data["completed"] = completed.dt.isoformat() # Handle categories categories = component.get("categories") if categories: todo_data["categories"] = self._extract_categories(categories) return todo_data return None except Exception as e: logger.error(f"Error parsing iCalendar todo: {e}") return None def _merge_ical_todo_properties( self, raw_ical: str, todo_data: Dict[str, Any], todo_uid: str ) -> str: """Merge new todo data into existing raw iCal while preserving all properties.""" try: logger.debug( f"Merging todo properties for {todo_uid}: {list(todo_data.keys())}" ) cal = Calendar.from_ical(raw_ical) for component in cal.walk(): if component.name == "VTODO": # Update only provided properties if "summary" in todo_data: component["SUMMARY"] = todo_data["summary"] if "description" in todo_data: component["DESCRIPTION"] = todo_data["description"] if "status" in todo_data: status_value = todo_data["status"].upper() component["STATUS"] = status_value logger.debug(f"Set STATUS to {status_value}") if "priority" in todo_data: component["PRIORITY"] = todo_data["priority"] if "percent_complete" in todo_data: percent_value = todo_data["percent_complete"] component["PERCENT-COMPLETE"] = percent_value logger.debug(f"Set PERCENT-COMPLETE to {percent_value}") # Import vDDDTypes at the beginning for datetime formatting from icalendar import vDDDTypes # Handle due date if "due" in todo_data: due_str = todo_data["due"] if due_str: due_dt = self._ensure_timezone_aware(due_str) component["DUE"] = vDDDTypes(due_dt) logger.debug(f"Set DUE to {due_dt}") # Handle start date if "dtstart" in todo_data: dtstart_str = todo_data["dtstart"] if dtstart_str: dtstart_dt = self._ensure_timezone_aware(dtstart_str) component["DTSTART"] = vDDDTypes(dtstart_dt) logger.debug(f"Set DTSTART to {dtstart_dt}") # Handle completed date if "completed" in todo_data: completed_str = todo_data["completed"] if completed_str: completed_dt = self._ensure_timezone_aware(completed_str) component["COMPLETED"] = vDDDTypes(completed_dt) logger.debug(f"Set COMPLETED to {completed_dt}") # Handle categories if "categories" in todo_data: categories_str = todo_data["categories"] if categories_str: component["CATEGORIES"] = categories_str.split(",") logger.debug(f"Set CATEGORIES to {categories_str}") # Update timestamps now = dt.datetime.now(dt.UTC) component["LAST-MODIFIED"] = vDDDTypes(now) component["DTSTAMP"] = vDDDTypes(now) break return cal.to_ical().decode("utf-8") except Exception as e: logger.error(f"Error merging iCal todo properties: {e}", exc_info=True) return self._create_ical_todo(todo_data, todo_uid) # ============= Helper Methods - Filtering ============= def _extract_categories(self, categories_obj) -> str: """Extract categories from icalendar object to string.""" if not categories_obj: return "" try: if hasattr(categories_obj, "cats"): # Handle Categories object with cats attribute return ", ".join(str(cat) for cat in categories_obj.cats) elif hasattr(categories_obj, "__iter__") and not isinstance( categories_obj, str ): # Handle list of vCategory objects or strings result = [] for cat in categories_obj: # Try to extract value from vCategory objects using to_ical() if hasattr(cat, "to_ical"): result.append(cat.to_ical().decode("utf-8")) else: result.append(str(cat)) return ", ".join(result) else: # Handle single category string or object if hasattr(categories_obj, "to_ical"): return categories_obj.to_ical().decode("utf-8") return str(categories_obj) except Exception as e: logger.warning(f"Error extracting categories: {e}") return str(categories_obj) def _apply_event_filters( self, events: List[Dict[str, Any]], filters: Dict[str, Any] ) -> List[Dict[str, Any]]: """Apply advanced filters to event list.""" return [ event for event in events if self._event_matches_filters(event, filters) ] def _event_matches_filters( self, event: Dict[str, Any], filters: Dict[str, Any] ) -> bool: """Check if an event matches the provided filters.""" try: # Filter by minimum attendees if "min_attendees" in filters: attendees = event.get("attendees", "") attendee_count = len(attendees.split(",")) if attendees else 0 if attendee_count < filters["min_attendees"]: return False # Filter by categories if "categories" in filters: event_categories = event.get("categories", "").lower() required_categories = [cat.lower() for cat in filters["categories"]] if not any(cat in event_categories for cat in required_categories): return False # Filter by status if "status" in filters: if event.get("status", "").upper() != filters["status"].upper(): return False # Filter by title contains if "title_contains" in filters: title = event.get("title", "").lower() search_term = filters["title_contains"].lower() if search_term not in title: return False # Filter by location contains if "location_contains" in filters: location = event.get("location", "").lower() search_term = filters["location_contains"].lower() if search_term not in location: return False return True except Exception: return True def _todo_matches_filters( self, todo: Dict[str, Any], filters: Dict[str, Any] ) -> bool: """Check if a todo matches the provided filters.""" try: # Filter by status if "status" in filters: if todo.get("status", "").upper() != filters["status"].upper(): return False # Filter by minimum priority if "min_priority" in filters: priority = todo.get("priority", 0) if priority == 0 or priority > filters["min_priority"]: return False # Filter by categories if "categories" in filters: todo_categories = todo.get("categories", "").lower() required_categories = [cat.lower() for cat in filters["categories"]] if not any(cat in todo_categories for cat in required_categories): return False # Filter by summary contains if "summary_contains" in filters: summary = todo.get("summary", "").lower() search_term = filters["summary_contains"].lower() if search_term not in summary: return False return True except Exception: return True # ============= Legacy Methods (for backward compatibility) ============= async def bulk_update_events( self, filter_criteria: Dict[str, Any], update_data: Dict[str, Any] ) -> Dict[str, Any]: """Bulk update events matching filter criteria.""" try: start_datetime = None end_datetime = None if "start_date" in filter_criteria and filter_criteria["start_date"]: start_datetime = dt.datetime.fromisoformat( filter_criteria["start_date"] ) if "end_date" in filter_criteria and filter_criteria["end_date"]: end_datetime = dt.datetime.fromisoformat(filter_criteria["end_date"]) events = await self.search_events_across_calendars( start_datetime=start_datetime, end_datetime=end_datetime, filters=filter_criteria, ) updated_count = 0 failed_count = 0 results = [] for event in events: try: await self.update_event( event["calendar_name"], event["uid"], update_data ) updated_count += 1 results.append( { "uid": event["uid"], "status": "updated", "title": event.get("title", ""), } ) except Exception as e: failed_count += 1 results.append( { "uid": event["uid"], "status": "failed", "error": str(e), "title": event.get("title", ""), } ) return { "total_found": len(events), "updated_count": updated_count, "failed_count": failed_count, "results": results, } except Exception as e: logger.error(f"Error in bulk update: {e}") raise async def find_availability( self, duration_minutes: int, attendees: Optional[List[str]] = None, start_datetime: Optional[dt.datetime] = None, end_datetime: Optional[dt.datetime] = None, constraints: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """Find available time slots for scheduling. Note: This is a simplified stub that returns empty list. Full implementation would require complex free/busy analysis. """ logger.warning("find_availability is not fully implemented with AsyncDavClient") return []

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cbcoutinho/nextcloud-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server