# -*- coding: utf-8 -*- """ Calendar Sync Manager. Periodische Synchronisation der Kalender-Events von Cloud-Providern. Cache-Verwaltung und Event-Abfragen. """ import asyncio from datetime import datetime, timedelta from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn from plugins.calendar.models import CalendarEvent, EventStatus from plugins.calendar.providers.base import CalendarProvider class CalendarSyncManager: """ Verwaltet die Synchronisation von Kalender-Events. - In-Memory Cache pro Kalender-Key - Lock gegen parallele Syncs - Fehlerbehandlung pro Kalender (ein Fehler blockiert nicht die anderen) """ def __init__( self, providers: dict[str, CalendarProvider], calendars_config: dict, lookahead_hours: int = 48, max_retries: int = 3, ) -> None: """ Args: providers: Provider-Instanzen {name: provider} calendars_config: Kalender-Konfiguration aus config.json lookahead_hours: Zeitfenster fuer Event-Abfrage in Stunden max_retries: Maximale Wiederholungen bei Fehlern """ self._providers = providers self._calendars_config = calendars_config self._lookahead_hours = lookahead_hours self._max_retries = max_retries self._event_cache: dict[str, list[CalendarEvent]] = {} self._sync_lock = asyncio.Lock() self._last_sync: datetime | None = None @property def last_sync(self) -> datetime | None: """Zeitpunkt der letzten Synchronisation.""" return self._last_sync async def sync_all(self) -> dict[str, int]: """ Synchronisiert alle konfigurierten Kalender. Returns: Dict mit {calendar_key: anzahl_events} """ async with self._sync_lock: results: dict[str, int] = {} for cal_key, cal_config in self._calendars_config.items(): try: events = await self.sync_calendar(cal_key) results[cal_key] = len(events) except Exception as e: perror(f"CalendarSync: Fehler bei '{cal_key}': {e}") results[cal_key] = -1 self._last_sync = datetime.now() total = sum(v for v in results.values() if v >= 0) pinfo(f"CalendarSync: {total} Events aus {len(results)} Kalendern synchronisiert") return results async def sync_calendar(self, calendar_key: str) -> list[CalendarEvent]: """ Synchronisiert einen einzelnen Kalender. Args: calendar_key: Kalender-Schluessel aus Config Returns: Liste der synchronisierten Events """ cal_config = self._calendars_config.get(calendar_key, {}) if not cal_config: pwarn(f"CalendarSync: Kalender '{calendar_key}' nicht in Config gefunden") return [] provider_name = cal_config.get("provider", "") provider = self._providers.get(provider_name) if not provider: pwarn(f"CalendarSync: Provider '{provider_name}' fuer '{calendar_key}' nicht verfuegbar") return [] if not provider.is_connected: # Reconnect-Versuch pdebug(f"CalendarSync: Provider '{provider_name}' nicht verbunden, versuche erneut...") connected = await provider.connect() if not connected: pwarn(f"CalendarSync: Provider '{provider_name}' weiterhin nicht erreichbar") return self._event_cache.get(calendar_key, []) calendar_id = cal_config.get("calendar_id", "") user = cal_config.get("user", "") # User-Email aus Provider-Config aufloeser user_email = self._resolve_user_email(provider_name, user) now = datetime.now() end = now + timedelta(hours=self._lookahead_hours) # Retry-Logik last_error: Exception | None = None for attempt in range(self._max_retries): try: events = await provider.fetch_events( calendar_id=calendar_id, start=now, end=end, user=user_email, ) # Abgesagte Termine filtern events = [e for e in events if e.status != EventStatus.CANCELLED] # Cache aktualisieren self._event_cache[calendar_key] = events pdebug(f"CalendarSync: '{calendar_key}' -> {len(events)} Events") return events except Exception as e: last_error = e if attempt < self._max_retries - 1: pdebug(f"CalendarSync: Retry {attempt + 1}/{self._max_retries} fuer '{calendar_key}'") await asyncio.sleep(2 ** attempt) perror(f"CalendarSync: '{calendar_key}' fehlgeschlagen nach {self._max_retries} Versuchen: {last_error}") return self._event_cache.get(calendar_key, []) def get_events( self, calendar_keys: list[str], start: datetime | None = None, end: datetime | None = None, ) -> list[CalendarEvent]: """ Gibt gecachte Events fuer die angegebenen Kalender zurueck. Args: calendar_keys: Liste von Kalender-Schluesseln start: Optionaler Start-Filter end: Optionaler End-Filter Returns: Sortierte Liste von CalendarEvent """ all_events: list[CalendarEvent] = [] for key in calendar_keys: events = self._event_cache.get(key, []) all_events.extend(events) # Zeitfilter anwenden if start: all_events = [e for e in all_events if e.end >= start] if end: all_events = [e for e in all_events if e.start <= end] # Nach Startzeit sortieren all_events.sort(key=lambda e: e.start) return all_events def get_next_event(self, calendar_keys: list[str]) -> CalendarEvent | None: """ Gibt den naechsten bevorstehenden Termin zurueck. Args: calendar_keys: Liste von Kalender-Schluesseln Returns: Naechstes CalendarEvent oder None """ now = datetime.now() events = self.get_events(calendar_keys, start=now) return events[0] if events else None def search_events( self, calendar_keys: list[str], query: str, ) -> list[CalendarEvent]: """ Sucht Events anhand eines Suchbegriffs. Durchsucht Titel, Beschreibung und Ort. Args: calendar_keys: Liste von Kalender-Schluesseln query: Suchbegriff Returns: Gefundene Events, sortiert nach Startzeit """ now = datetime.now() events = self.get_events(calendar_keys, start=now) query_lower = query.lower() results: list[CalendarEvent] = [] for event in events: if ( query_lower in event.title.lower() or query_lower in event.description.lower() or query_lower in event.location.lower() ): results.append(event) return results def get_all_cached_events(self) -> list[CalendarEvent]: """Gibt alle gecachten Events zurueck (fuer Notifications).""" all_events: list[CalendarEvent] = [] for events in self._event_cache.values(): all_events.extend(events) return all_events def get_calendar_key_for_event(self, event: CalendarEvent) -> str: """Gibt den Kalender-Key fuer ein Event zurueck.""" for key, events in self._event_cache.items(): if any(e.event_id == event.event_id for e in events): return key return "" def _resolve_user_email(self, provider_name: str, user: str) -> str: """Loest einen User-Key in eine Email-Adresse auf.""" if not user: return "" provider = self._providers.get(provider_name) if not provider: return user user_mapping = provider.config.get("user_mapping", {}) return user_mapping.get(user, user)