caldav2google/src/sync_logic.py

237 lines
8.4 KiB
Python

"""Logic for syncing events between a local calendar and Google Calendar."""
import json
import os
import time
from datetime import datetime
from typing import Any, Dict, List, Tuple
from googleapiclient.discovery import Resource
from logger import setup_logger
logger = setup_logger(__name__)
EventDict = Dict[str, Any]
EventsDict = Dict[str, EventDict]
error_events: List[EventDict] = []
def _sanitize_event_for_json(event_data: Dict[str, Any]) -> Dict[str, Any]:
"""Sanitize event data to ensure it's JSON serializable.
Args:
event_data: Dictionary containing event details.
Returns:
Dict[str, Any]: Sanitized dictionary.
"""
sanitized = event_data.copy()
if "rrule" in sanitized and sanitized["rrule"]:
rrule = sanitized["rrule"].copy()
for key, value in rrule.items():
if isinstance(value, list):
rrule[key] = [item.isoformat() if isinstance(item, datetime) else item for item in value]
sanitized["rrule"] = rrule
return sanitized
def compare_events(
local_events: EventsDict,
server_events: EventsDict,
) -> Tuple[List[EventDict], List[EventDict], List[EventDict]]:
"""Compare local and server events to determine changes.
Args:
local_events: Dictionary of locally stored events.
server_events: Dictionary of events from the server.
Returns:
Tuple containing lists of new, updated, and deleted events.
"""
new_events: List[EventDict] = []
updated_events: List[EventDict] = []
deleted_events: List[EventDict] = []
logger.info(f"Comparing {len(server_events)} server events with {len(local_events)} local events")
# Find new and updated events
for uid, event in server_events.items():
if uid not in local_events:
logger.debug(f"New event found: {event['summary']} (UID: {uid})")
new_events.append(event)
elif event["last_modified"] != local_events[uid].get("last_modified"):
logger.debug(f"Modified event found: {event['summary']} (UID: {uid})")
event["google_event_id"] = local_events[uid].get("google_event_id")
updated_events.append(event)
# Find deleted events
for uid, event in local_events.items():
if uid not in server_events:
logger.debug(f"Deleted event found: {event['summary']} (UID: {uid})")
deleted_events.append(event)
logger.info(
f"Found {len(new_events)} new events, {len(updated_events)} modified events, "
f"and {len(deleted_events)} deleted events",
)
return new_events, updated_events, deleted_events
def load_local_sync(file_path: str) -> EventsDict:
"""Load the locally synced events from a JSON file.
Args:
file_path: Path to the JSON file.
Returns:
EventsDict: Dictionary of previously synced events.
"""
logger.info(f"Loading local sync data from {file_path}")
if not os.path.exists(file_path):
logger.info("No existing sync file found, starting fresh")
return {}
try:
with open(file_path, "r") as file:
events = json.load(file)
logger.info(f"Successfully loaded {len(events)} events from local sync file")
return events
except json.JSONDecodeError as e:
logger.error(f"Error decoding JSON from {file_path}: {str(e)}")
return {}
except Exception as e:
logger.error(f"Unexpected error loading sync file: {str(e)}")
return {}
def save_local_sync(file_path: str, events: EventsDict) -> None:
"""Save the events to the local sync JSON file.
Args:
file_path: Path to the JSON file.
events: Dictionary of events to save.
"""
logger.info(f"Saving {len(events)} events to local sync file")
sanitized_events = {}
# Sanitize events
for event_id, event_data in events.items():
try:
sanitized_events[event_id] = _sanitize_event_for_json(event_data)
except Exception as e:
logger.error(f"Failed to sanitize event {event_id} ({event_data.get('summary', 'No summary')}): {str(e)}")
continue
# Save to file
try:
with open(file_path, "w") as file:
json.dump(sanitized_events, file, indent=4)
logger.info(f"Successfully saved {len(sanitized_events)} events to {file_path}")
except Exception as e:
logger.error(f"Failed to save sync file: {str(e)}")
logger.debug("Attempting to identify problematic events...")
for event_id, event_data in sanitized_events.items():
try:
json.dumps(event_data)
except TypeError as e:
logger.error(f"JSON serialization failed for event: {event_id}")
logger.error(f"Event summary: {event_data.get('summary', 'No summary')}")
logger.error(f"Error: {str(e)}")
# Debug field values
for key, value in event_data.items():
try:
json.dumps({key: value})
except TypeError:
logger.error(f"Problematic field: {key} = {value} (type: {type(value)})")
def add_event_to_google(service: Resource, event: EventDict, calendar_id: str) -> None:
"""Add a single event to Google Calendar.
Args:
service: Authenticated Google Calendar API service object.
event: Dictionary containing event details.
calendar_id: ID of the target Google Calendar.
"""
logger.info(f"Processing event: {event['summary']} (UID: {event['uid']})")
try:
# Prepare event data
google_event = {
"summary": event["summary"],
"description": event.get("description", ""),
"location": event.get("location", ""),
"start": {"dateTime": event["start"], "timeZone": "UTC"},
"end": {"dateTime": event["end"], "timeZone": "UTC"},
}
# Handle recurring events
if event.get("rrule"):
logger.debug(f"Processing recurring event rules for {event['summary']}")
rrule_parts = []
for key, value in event["rrule"].items():
if isinstance(value, list):
value = [item.isoformat() if isinstance(item, datetime) else item for item in value]
value = ",".join(str(v) for v in value)
rrule_parts.append(f"{key}={value}")
google_event["recurrence"] = [f"RRULE:{';'.join(rrule_parts)}"]
# Handle excluded dates
if event.get("exdate"):
logger.debug(f"Processing {len(event['exdate'])} excluded dates")
exdates = [f"EXDATE;TZID=UTC:{date}" for date in event["exdate"]]
google_event["recurrence"].extend(exdates)
# Add event to Google Calendar
created_event = (
service.events()
.insert(
calendarId=calendar_id,
body=google_event,
)
.execute()
)
event["google_event_id"] = created_event["id"]
logger.info(f"Successfully created event: {event['summary']} (Google ID: {created_event['id']})")
# Rate limiting
time.sleep(0.5)
except Exception as e:
logger.error(f"Failed to add event {event['summary']} (UID: {event['uid']})")
logger.error(f"Error: {str(e)}")
error_events.append(event)
def delete_event_from_google(service: Resource, event: EventDict, calendar_id: str) -> None:
"""Delete a single event from Google Calendar.
Args:
service: Authenticated Google Calendar API service object.
event: Dictionary containing event details.
calendar_id: ID of the target Google Calendar.
"""
google_event_id = event.get("google_event_id")
if not google_event_id:
error_msg = f"Cannot delete event {event['summary']} (UID: {event['uid']}): missing Google Calendar ID"
logger.error(error_msg)
raise ValueError(error_msg)
try:
logger.info(f"Deleting event: {event['summary']} (Google ID: {google_event_id})")
service.events().delete(calendarId=calendar_id, eventId=google_event_id).execute()
logger.info(f"Successfully deleted event: {event['summary']}")
# Rate limiting
time.sleep(0.5)
except Exception as e:
logger.error(f"Failed to delete event: {event['summary']} (UID: {event['uid']})")
logger.error(f"Error: {str(e)}")