""" Utility module for handling calendar events and iCalendar operations. """ import re import os from datetime import datetime, timedelta from dateutil import parser from dateutil.tz import UTC import glob def truncate_id(text, first=8, last=8): """ Truncate long IDs or filenames to show just the first and last few characters. Args: text: The ID or filename to truncate first: Number of characters to keep from the beginning last: Number of characters to keep from the end Returns: Truncated string with ellipsis in the middle """ if not text or len(text) <= first + last + 3: return text return f"{text[:first]}...{text[-last:]}" def clean_text(text): """ Clean text by removing instances of 3 or more consecutive underscores which can affect readability. Args: text: Text to clean Returns: Cleaned text """ if not text: return "" # Replace 3 or more consecutive underscores with 2 underscores return re.sub(r"_{3,}", "__", text) def escape_ical_text(text): """ Escape text for iCalendar format according to RFC 5545. Args: text: Text to escape Returns: Escaped text """ if not text: return "" # First clean multiple underscores text = clean_text(text) text = text.replace("\\", "\\\\") text = text.replace("\n", "\\n") text = text.replace(",", "\\,") text = text.replace(";", "\\;") return text async def fetch_calendar_events( headers, days_back=1, days_forward=6, fetch_function=None, start_date=None, end_date=None, ): """ Fetch calendar events from Microsoft Graph API. Args: headers: Authentication headers for Microsoft Graph API days_back: Number of days to look back (default: 1) days_forward: Number of days to look forward (default: 6) fetch_function: Async function to use for fetching data (default: None) Should accept URL and headers as parameters start_date: Optional explicit start date (datetime object) end_date: Optional explicit end date (datetime object) Returns: Tuple of (events list, total_events count) """ if fetch_function is None: raise ValueError("fetch_function is required for API calls") # Calculate date range if start_date is None: start_date = datetime.now().replace(hour=0, minute=0, second=0) - timedelta( days=days_back ) if end_date is None: end_of_today = datetime.now().replace(hour=23, minute=59, second=59) end_date = end_of_today + timedelta(days=days_forward) # Build the API URL event_base_url = f"https://graph.microsoft.com/v1.0/me/calendarView?startDateTime={start_date.isoformat()}&endDateTime={end_date.isoformat()}" calendar_url = f"{event_base_url}&$top=100&$select=start,end,id,iCalUId,subject,bodyPreview,webLink,location,recurrence,showAs,responseStatus,onlineMeeting,lastModifiedDateTime" # Fetch total count for progress reporting (if needed) total_event_url = f"{event_base_url}&$count=true&$select=id" try: total_response = await fetch_function(total_event_url, headers) total_events = total_response.get("@odata.count", 0) except Exception as e: print(f"Error fetching total events count: {e}") total_events = 0 # Fetch all calendar events, handling pagination events = [] while calendar_url: try: response_data = await fetch_function(calendar_url, headers) if response_data: events.extend(response_data.get("value", [])) # Get the next page URL from @odata.nextLink calendar_url = response_data.get("@odata.nextLink") else: print("Received empty response from calendar API") break except Exception as e: print(f"Error fetching calendar events: {e}") break # Only return the events and total_events return events, total_events def write_event_to_ical(f, event, start, end): """ Write a single event to an iCalendar file. Args: f: File-like object to write to event: Dictionary containing event data start: Start datetime with timezone information end: End datetime with timezone information """ # Preserve the original timezones start_tz = start.tzinfo end_tz = end.tzinfo f.write(f"BEGIN:VEVENT\nSUMMARY:{escape_ical_text(event['subject'])}\n") # Handle multi-line description properly description = event.get("bodyPreview", "") if description: escaped_description = escape_ical_text(description) f.write(f"DESCRIPTION:{escaped_description}\n") f.write(f"UID:{event.get('iCalUId', '')}\n") f.write( f"LOCATION:{escape_ical_text(event.get('location', {}).get('displayName', ''))}\n" ) f.write(f"CLASS:{event.get('showAs', '')}\n") f.write(f"STATUS:{event.get('responseStatus', {}).get('response', '')}\n") if "onlineMeeting" in event and event["onlineMeeting"]: f.write(f"URL:{event.get('onlineMeeting', {}).get('joinUrl', '')}\n") # Write start and end times with timezone info in iCalendar format if start.tzinfo == UTC: f.write(f"DTSTART:{start.strftime('%Y%m%dT%H%M%SZ')}\n") else: tz_name = start_tz.tzname(None) if start_tz else "UTC" f.write(f"DTSTART;TZID={tz_name}:{start.strftime('%Y%m%dT%H%M%S')}\n") if end.tzinfo == UTC: f.write(f"DTEND:{end.strftime('%Y%m%dT%H%M%SZ')}\n") else: tz_name = end_tz.tzname(None) if end_tz else "UTC" f.write(f"DTEND;TZID={tz_name}:{end.strftime('%Y%m%dT%H%M%S')}\n") # Handle recurrence rules if "recurrence" in event and event["recurrence"]: for rule in event["recurrence"]: if rule.startswith("RRULE"): rule_parts = rule.split(";") new_rule_parts = [] for part in rule_parts: if part.startswith("UNTIL="): until_value = part.split("=")[1] until_date = parser.isoparse(until_value) if start.tzinfo is not None and until_date.tzinfo is None: until_date = until_date.replace(tzinfo=start.tzinfo) new_rule_parts.append( f"UNTIL={until_date.strftime('%Y%m%dT%H%M%SZ')}" ) else: new_rule_parts.append(part) rule = ";".join(new_rule_parts) f.write(f"{rule}\n") f.write("END:VEVENT\n") def save_events_to_vdir(events, org_vdir_path, progress, task_id, dry_run=False): """ Save events to vdir format (one file per event). Args: events: List of event dictionaries org_vdir_path: Path to save the event files progress: Progress object for updating UI task_id: Task ID for progress tracking dry_run: If True, don't actually write files Returns: Number of events processed """ if dry_run: progress.console.print( f"[DRY-RUN] Would save {len(events)} events to vdir format in {org_vdir_path}" ) return len(events) os.makedirs(org_vdir_path, exist_ok=True) progress.console.print(f"Saving events to vdir format in {org_vdir_path}...") # Create a dictionary to track existing files and their metadata existing_files = {} for file_path in glob.glob(os.path.join(org_vdir_path, "*.ics")): file_name = os.path.basename(file_path) file_mod_time = os.path.getmtime(file_path) existing_files[file_name] = {"path": file_path, "mtime": file_mod_time} processed_files = set() for event in events: progress.advance(task_id) if "start" not in event or "end" not in event: continue # Parse start and end times with timezone information start = parser.isoparse(event["start"]["dateTime"]) end = parser.isoparse(event["end"]["dateTime"]) uid = event.get("iCalUId", "") if not uid: # Generate a unique ID if none exists uid = f"outlook-{event.get('id', '')}" # Create a filename based on the UID safe_filename = re.sub(r"[^\w\-]", "_", uid) + ".ics" event_path = os.path.join(org_vdir_path, safe_filename) processed_files.add(safe_filename) # Check if we need to update this file should_update = True if safe_filename in existing_files: # Only update if the event has been modified since the file was last updated if "lastModifiedDateTime" in event: last_modified = parser.isoparse( event["lastModifiedDateTime"] ).timestamp() file_mtime = existing_files[safe_filename]["mtime"] if last_modified <= file_mtime: should_update = False progress.console.print( f"Skipping unchanged event: {event['subject']}" ) if should_update: with open(event_path, "w") as f: f.write("BEGIN:VCALENDAR\nVERSION:2.0\n") write_event_to_ical(f, event, start, end) f.write("END:VCALENDAR\n") # Remove files for events that no longer exist in the calendar view for file_name in existing_files: if file_name not in processed_files: progress.console.print( f"Removing obsolete event file: {truncate_id(file_name)}" ) os.remove(existing_files[file_name]["path"]) # Create sync timestamp to track when this download sync completed timestamp_file = os.path.join(org_vdir_path, ".sync_timestamp") try: with open(timestamp_file, "w") as f: f.write(str(datetime.now().timestamp())) progress.console.print(f"Updated sync timestamp for calendar monitoring") except IOError as e: progress.console.print(f"Warning: Could not create sync timestamp: {e}") progress.console.print(f"Saved {len(events)} events to {org_vdir_path}") return len(events) def save_events_to_file(events, output_file, progress, task_id, dry_run=False): """ Save all events to a single iCalendar file. Args: events: List of event dictionaries output_file: Path to the output file progress: Progress object for updating UI task_id: Task ID for progress tracking dry_run: If True, don't actually write the file Returns: Number of events processed """ if dry_run: progress.console.print(f"[DRY-RUN] Would save events to {output_file}") return len(events) os.makedirs(os.path.dirname(output_file), exist_ok=True) progress.console.print(f"Saving events to {output_file}...") with open(output_file, "w") as f: f.write("BEGIN:VCALENDAR\nVERSION:2.0\n") for event in events: progress.advance(task_id) if "start" in event and "end" in event: # Parse start and end times with timezone information start = parser.isoparse(event["start"]["dateTime"]) end = parser.isoparse(event["end"]["dateTime"]) write_event_to_ical(f, event, start, end) f.write("END:VCALENDAR\n") progress.console.print(f"Saved events to {output_file}") return len(events)