#!/usr/bin/env python3 """Read GNOME's Evolution Data Server calendars for the Panama shell.""" from __future__ import annotations import json import re import sys import time from collections.abc import Iterable from typing import Any from urllib.parse import urlsplit try: import gi gi.require_version("ECal", "2.0") gi.require_version("EDataServer", "1.2") gi.require_version("ICalGLib", "3.0") from gi.repository import ECal, EDataServer, Gio, GLib, ICalGLib EDS_READY = True except (ImportError, ValueError): ECal = EDataServer = Gio = GLib = ICalGLib = None EDS_READY = False MEETING_HOST_SUFFIXES = ( "meet.google.com", "zoom.us", "teams.microsoft.com", "teams.live.com", "webex.com", "meet.jit.si", ) URL_PATTERN = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE) COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$") FALLBACK_COLOR = "#82aaff" def stable_event_id(source_id: str, uid: str, recurrence_id: str) -> str: return f"{source_id}:{uid}:{recurrence_id}" def sort_events(events: list[dict[str, object]]) -> list[dict[str, object]]: return sorted( events, key=lambda event: ( not bool(event["allDay"]), int(event["start"]), str(event["id"]), ), ) def is_allowed_meeting_host(host: str) -> bool: normalized = host.lower().rstrip(".") return any( normalized == suffix or normalized.endswith("." + suffix) for suffix in MEETING_HOST_SUFFIXES ) def find_join_url(values: Iterable[str | None]) -> str: for value in values: if not value: continue for match in URL_PATTERN.findall(value): candidate = match.rstrip("),.;!?]}") try: parsed = urlsplit(candidate) except ValueError: continue if parsed.scheme.lower() != "https" or not parsed.hostname: continue if is_allowed_meeting_host(parsed.hostname): return candidate return "" def _safe_component_value(component: Any, getter: str) -> str: try: return (getattr(component, getter)() or "").strip() except (AttributeError, TypeError): return "" def normalize_instance( source_meta: dict[str, str], component: Any, instance_start: Any, instance_end: Any, ) -> dict[str, object]: uid = (component.get_uid() or "").strip() if not uid: raise ValueError("calendar component has no UID") start = int(instance_start.as_timet()) end = int(instance_end.as_timet()) if end < start: raise ValueError("calendar component ends before it starts") recurrence = component.get_recurrenceid() recurrence_id = str(start) if recurrence is not None and not recurrence.is_null_time(): recurrence_id = recurrence.as_ical_string() summary = _safe_component_value(component, "get_summary") or "Untitled event" location = _safe_component_value(component, "get_location") description = _safe_component_value(component, "get_description") component_url = "" try: url_property = component.get_first_property(ICalGLib.PropertyKind.URL_PROPERTY) if url_property is not None: component_url = (url_property.get_url() or "").strip() except (AttributeError, TypeError): component_url = "" all_day = bool(instance_start.is_date() or instance_end.is_date()) return { "id": stable_event_id(source_meta["id"], uid, recurrence_id), "sourceId": source_meta["id"], "uid": uid, "summary": summary, "start": start, "end": end, "allDay": all_day, "location": location, "joinUrl": find_join_url([component_url, location, description]), } def _source_meta(source: Any) -> dict[str, str]: extension = source.get_extension(EDataServer.SOURCE_EXTENSION_CALENDAR) color = extension.get_color() or FALLBACK_COLOR if not COLOR_PATTERN.fullmatch(color): color = FALLBACK_COLOR return { "id": source.get_uid(), "name": (source.get_display_name() or "Calendar").strip(), "color": color.lower(), } def _unavailable_snapshot() -> dict[str, object]: return { "ok": False, "generatedAt": int(time.time()), "sources": [], "events": [], "errors": [{"code": "eds-unavailable"}], } def probe() -> dict[str, object]: if not EDS_READY: return {"eds": False, "sourceRegistry": False, "enabledSources": 0} try: registry = EDataServer.SourceRegistry.new_sync(None) sources = registry.list_enabled(EDataServer.SOURCE_EXTENSION_CALENDAR) except Exception: return {"eds": True, "sourceRegistry": False, "enabledSources": 0} return { "eds": True, "sourceRegistry": True, "enabledSources": len(sources), } def collect_snapshot(start: int, end: int) -> dict[str, object]: if not EDS_READY: return _unavailable_snapshot() if start < 0 or end <= start: return { **_unavailable_snapshot(), "errors": [{"code": "invalid-range"}], } try: registry = EDataServer.SourceRegistry.new_sync(None) enabled_sources = registry.list_enabled(EDataServer.SOURCE_EXTENSION_CALENDAR) except Exception: return _unavailable_snapshot() sources: list[dict[str, str]] = [] events_by_id: dict[str, dict[str, object]] = {} errors: list[dict[str, str]] = [] for source in enabled_sources: source_meta = _source_meta(source) sources.append(source_meta) malformed_event = False def on_instance( component: Any, instance_start: Any, instance_end: Any, _cancellable: Any, _callback_data: Any, ) -> bool: nonlocal malformed_event try: event = normalize_instance( source_meta, component, instance_start, instance_end, ) events_by_id[str(event["id"])] = event except (AttributeError, TypeError, ValueError): malformed_event = True return True try: client = ECal.Client.connect_sync( source, ECal.ClientSourceType.EVENTS, 5, None, ) if client is None: raise RuntimeError("calendar client unavailable") client.generate_instances_sync(start, end, None, on_instance, None) except Exception: errors.append({"sourceId": source_meta["id"], "code": "unavailable"}) continue if malformed_event: errors.append({"sourceId": source_meta["id"], "code": "event-invalid"}) sources.sort(key=lambda source: (source["name"].casefold(), source["id"])) return { "ok": True, "generatedAt": int(time.time()), "sources": sources, "events": sort_events(list(events_by_id.values())), "errors": errors, } def _write_snapshot(snapshot: dict[str, object]) -> bool: try: sys.stdout.write(json.dumps(snapshot, separators=(",", ":")) + "\n") sys.stdout.flush() except BrokenPipeError: return False return True def watch(start: int, end: int) -> int: if not EDS_READY: _write_snapshot(_unavailable_snapshot()) return 1 loop = GLib.MainLoop() registry = EDataServer.SourceRegistry.new_sync(None) debounce_source = 0 subscriptions: list[int] = [] def emit_snapshot() -> bool: nonlocal debounce_source debounce_source = 0 if not _write_snapshot(collect_snapshot(start, end)): loop.quit() return GLib.SOURCE_REMOVE def schedule_refresh(*_args: Any) -> None: nonlocal debounce_source if debounce_source: GLib.source_remove(debounce_source) debounce_source = GLib.timeout_add(350, emit_snapshot) def periodic_refresh() -> bool: emit_snapshot() return GLib.SOURCE_CONTINUE for signal_name in ("source-added", "source-removed", "source-changed"): try: registry.connect(signal_name, schedule_refresh) except TypeError: pass try: bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) for signal_name in ( "EventsAddedOrUpdated", "EventsRemoved", "ClientDisappeared", ): subscriptions.append( bus.signal_subscribe( "org.gnome.Shell.CalendarServer", "org.gnome.Shell.CalendarServer", signal_name, "/org/gnome/Shell/CalendarServer", None, Gio.DBusSignalFlags.NONE, schedule_refresh, None, ) ) proxy = Gio.DBusProxy.new_sync( bus, Gio.DBusProxyFlags.NONE, None, "org.gnome.Shell.CalendarServer", "/org/gnome/Shell/CalendarServer", "org.gnome.Shell.CalendarServer", None, ) proxy.call_sync( "SetTimeRange", GLib.Variant("(xxb)", (start, end, True)), Gio.DBusCallFlags.NONE, -1, None, ) except Exception: bus = None if not _write_snapshot(collect_snapshot(start, end)): return 0 GLib.timeout_add_seconds(600, periodic_refresh) try: loop.run() except KeyboardInterrupt: pass finally: if bus is not None: for subscription in subscriptions: bus.signal_unsubscribe(subscription) return 0 def _usage_error() -> dict[str, object]: return { "ok": False, "generatedAt": int(time.time()), "sources": [], "events": [], "errors": [{"code": "usage"}], } def main(argv: list[str]) -> int: command = argv[1] if len(argv) > 1 else "" if command == "probe" and len(argv) == 2: _write_snapshot(probe()) return 0 if command in {"query", "watch"} and len(argv) == 4: try: start = int(argv[2]) end = int(argv[3]) except ValueError: _write_snapshot(_usage_error()) return 64 if command == "query": snapshot = collect_snapshot(start, end) _write_snapshot(snapshot) return 0 if snapshot["ok"] else 1 return watch(start, end) _write_snapshot(_usage_error()) return 64 if __name__ == "__main__": raise SystemExit(main(sys.argv))