diff --git a/glance-system/glance-helper.yaml b/glance-system/glance-helper.yaml index 7b2e8c5..685d075 100644 --- a/glance-system/glance-helper.yaml +++ b/glance-system/glance-helper.yaml @@ -925,6 +925,217 @@ data: }, ensure_ascii=False), media_type="application/json; charset=utf-8" ) + + # ================================ + # Google Calendar iCal Integration + # ================================ + from icalendar import Calendar as ICalCalendar + from dateutil.rrule import rrulestr + from dateutil import parser as dateutil_parser + + # Calendar configuration - loaded from environment + CALENDAR_ICAL_URLS = os.getenv("CALENDAR_ICAL_URLS", "") # JSON: {"name": "url", ...} + + def _parse_ical_urls() -> dict[str, str]: + """Parse CALENDAR_ICAL_URLS environment variable.""" + if not CALENDAR_ICAL_URLS: + return {} + try: + return json.loads(CALENDAR_ICAL_URLS) + except Exception as e: + print(f"Error parsing CALENDAR_ICAL_URLS: {e}") + return {} + + def _fetch_ical(url: str, timeout: int = 15) -> str: + """Fetch iCal content from URL.""" + try: + r = requests.get(url, timeout=timeout, headers={"User-Agent": UA}) + r.raise_for_status() + return r.text + except Exception as e: + print(f"Error fetching iCal from {url}: {e}") + return "" + + def _parse_ical_events(ical_text: str, calendar_name: str, days_ahead: int = 30) -> list[dict]: + """ + Parse iCal text and return upcoming events. + Handles both single events and recurring events. + """ + events = [] + if not ical_text: + return events + + try: + tz = ZoneInfo("Europe/Budapest") + except Exception: + tz = timezone.utc + + now = datetime.now(tz) + cutoff = now + timedelta(days=days_ahead) + + try: + cal = ICalCalendar.from_ical(ical_text) + + for component in cal.walk(): + if component.name != "VEVENT": + continue + + summary = str(component.get("SUMMARY", "No title")) + description = str(component.get("DESCRIPTION", "")) if component.get("DESCRIPTION") else "" + location = str(component.get("LOCATION", "")) if component.get("LOCATION") else "" + uid = str(component.get("UID", "")) + + # Get start and end times + dtstart = component.get("DTSTART") + dtend = component.get("DTEND") + + if not dtstart: + continue + + dtstart_val = dtstart.dt + dtend_val = dtend.dt if dtend else None + + # Handle all-day events (date vs datetime) + is_all_day = not isinstance(dtstart_val, datetime) + + if is_all_day: + # All-day event - convert date to datetime + dtstart_val = datetime.combine(dtstart_val, datetime.min.time(), tzinfo=tz) + if dtend_val and not isinstance(dtend_val, datetime): + dtend_val = datetime.combine(dtend_val, datetime.min.time(), tzinfo=tz) + else: + # Make sure datetime is timezone-aware + if dtstart_val.tzinfo is None: + dtstart_val = dtstart_val.replace(tzinfo=tz) + else: + dtstart_val = dtstart_val.astimezone(tz) + + if dtend_val: + if dtend_val.tzinfo is None: + dtend_val = dtend_val.replace(tzinfo=tz) + else: + dtend_val = dtend_val.astimezone(tz) + + # Check for recurring events + rrule = component.get("RRULE") + + if rrule: + # Handle recurring events + try: + rrule_str = rrule.to_ical().decode("utf-8") + rule = rrulestr(rrule_str, dtstart=dtstart_val) + + # Get occurrences within our window + occurrences = list(rule.between(now, cutoff, inc=True))[:10] # Limit to 10 occurrences + + for occ in occurrences: + if occ.tzinfo is None: + occ = occ.replace(tzinfo=tz) + + # Calculate end time for this occurrence + if dtend_val and dtstart_val: + duration = dtend_val - dtstart_val + occ_end = occ + duration + else: + occ_end = None + + events.append({ + "title": summary, + "description": description, + "location": location, + "calendar": calendar_name, + "start": occ.isoformat(), + "start_unix": int(occ.timestamp()), + "end": occ_end.isoformat() if occ_end else None, + "end_unix": int(occ_end.timestamp()) if occ_end else None, + "is_all_day": is_all_day, + "uid": uid + }) + except Exception as e: + print(f"Error parsing RRULE for event '{summary}': {e}") + # Fall back to single event + if now <= dtstart_val <= cutoff: + events.append({ + "title": summary, + "description": description, + "location": location, + "calendar": calendar_name, + "start": dtstart_val.isoformat(), + "start_unix": int(dtstart_val.timestamp()), + "end": dtend_val.isoformat() if dtend_val else None, + "end_unix": int(dtend_val.timestamp()) if dtend_val else None, + "is_all_day": is_all_day, + "uid": uid + }) + else: + # Single event + if now <= dtstart_val <= cutoff: + events.append({ + "title": summary, + "description": description, + "location": location, + "calendar": calendar_name, + "start": dtstart_val.isoformat(), + "start_unix": int(dtstart_val.timestamp()), + "end": dtend_val.isoformat() if dtend_val else None, + "end_unix": int(dtend_val.timestamp()) if dtend_val else None, + "is_all_day": is_all_day, + "uid": uid + }) + + except Exception as e: + print(f"Error parsing iCal for {calendar_name}: {e}") + + return events + + @APP.get("/calendar/events") + def calendar_events_api( + count: int = Query(default=5, ge=1, le=50, description="Number of events to return"), + days: int = Query(default=30, ge=1, le=365, description="Days to look ahead") + ): + """ + Returns upcoming calendar events from configured iCal feeds. + Merges multiple calendars and sorts by start time. + """ + calendars = _parse_ical_urls() + + if not calendars: + return Response( + content=json.dumps({ + "error": "No calendars configured", + "events": [], + "count": 0, + "fetched_at_unix": int(time.time()) + }, ensure_ascii=False), + media_type="application/json; charset=utf-8" + ) + + all_events = [] + calendar_status = {} + + for name, url in calendars.items(): + ical_text = _fetch_ical(url) + if ical_text: + events = _parse_ical_events(ical_text, name, days) + all_events.extend(events) + calendar_status[name] = {"status": "ok", "events": len(events)} + else: + calendar_status[name] = {"status": "error", "events": 0} + + # Sort by start time and limit + all_events.sort(key=lambda e: e["start_unix"]) + limited_events = all_events[:count] + + return Response( + content=json.dumps({ + "events": limited_events, + "count": len(limited_events), + "total_available": len(all_events), + "calendars": calendar_status, + "fetched_at_unix": int(time.time()) + }, ensure_ascii=False), + media_type="application/json; charset=utf-8" + ) --- apiVersion: apps/v1 kind: Deployment @@ -957,7 +1168,7 @@ spec: apt-get update; apt-get install -y --no-install-recommends curl ca-certificates iputils-ping dnsutils tzdata net-tools; rm -rf /var/lib/apt/lists/*; - pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 prometheus-client; + pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 prometheus-client icalendar python-dateutil; python -c "import uvicorn; uvicorn.run('app:APP', host='0.0.0.0', port=8000)" command: - /bin/sh @@ -986,6 +1197,9 @@ spec: value: http://version-checker.version-checker-system.svc.cluster.local:8080/metrics - name: VERSION_CHECKER_EXCLUDE_IMAGES value: "(^|.*/)(busybox|redis|alpine|nginx|mariadb|mysql|valkey)$|^longhornio.*$|(^|.*/)postgres.*$|^registry\\.k8s\\.io/ingress-nginx/.*$" + # Calendar iCal URLs (JSON object: {"name": "url", ...}) + - name: CALENDAR_ICAL_URLS + value: '{"Órák": "https://calendar.google.com/calendar/ical/b2884faf3db792ac082a6206057552c79080716efd5f966e169a41fc500e1c1c%40group.calendar.google.com/public/basic.ics", "Családi": "https://calendar.google.com/calendar/ical/family05271575732400990671%40group.calendar.google.com/public/basic.ics"}' image: python:3.12-bookworm imagePullPolicy: IfNotPresent name: glance-helper diff --git a/glance-system/glance-orsi.yaml b/glance-system/glance-orsi.yaml index fa72387..3b1e4e0 100644 --- a/glance-system/glance-orsi.yaml +++ b/glance-system/glance-orsi.yaml @@ -560,6 +560,121 @@ data: # ---------- RIGHT COLUMN ---------- - size: small widgets: + # Calendar Events Widget (Órák & Családi) + - type: custom-api + title: Next Events + cache: 5m + url: http://glance-helper.glance-system.svc.cluster.local:8000/calendar/events + parameters: + count: 10 + days: 14 + template: | + {{ $events := .JSON.Array "events" }} + {{ $count := len $events }} + {{ $showCount := 5 }} + {{ $hasMore := gt $count $showCount }} + + + +
+
+ Next events ({{ $count }}) +
+ + {{ if lt $count 1 }} +
No event in near future.
+ {{ else }} + {{ if $hasMore }}{{ end }} +
+ {{ range $i, $e := $events }} + {{ $isExtra := ge $i $showCount }} + {{ $start := $e.String "start" }} + {{ $isAllDay := $e.Bool "is_all_day" }} + {{ $calendar := $e.String "calendar" }} + {{ $title := $e.String "title" }} + +
+
+
{{ $title }}
+
{{ $calendar }}
+
+
+
{{ formatTime $start "Jan 2" }}
+ {{ if $isAllDay }} +
Whole Day
+ {{ else }} +
{{ formatTime $start "15:04" }}
+ {{ end }} +
+
+ {{ end }} +
+ {{ if $hasMore }} + + + {{ end }} + {{ end }} +
+ - type: rss title: News & Feeds limit: 15