301 lines
11 KiB
Python
301 lines
11 KiB
Python
"""
|
|
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'])
|
|
|
|
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)
|