added events calendar

This commit is contained in:
2026-01-23 11:57:29 +01:00
parent e371ced1db
commit 59df30f672
2 changed files with 330 additions and 1 deletions
+215 -1
View File
@@ -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
+115
View File
@@ -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 }}
<style>
.cal-widget { display: flex; flex-direction: column; gap: 6px; }
.cal-meta { opacity: .65; font-size: 12px; display: flex; justify-content: space-between; align-items: center; }
.cal-empty { opacity: 0.5; font-size: 13px; padding: 8px 0; }
.cal-list { display: flex; flex-direction: column; gap: 4px; }
.cal-item {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255,255,255,0.04);
align-items: start;
}
.cal-item:hover { background: rgba(255,255,255,0.08); }
.cal-title {
font-weight: 600;
opacity: 0.95;
font-size: 13px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
}
.cal-source {
font-size: 10px;
opacity: 0.5;
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cal-time {
font-size: 12px;
opacity: 0.7;
text-align: right;
white-space: nowrap;
line-height: 1.4;
}
.cal-date { font-weight: 600; }
.cal-hour { opacity: 0.8; }
.cal-allday {
font-size: 10px;
opacity: 0.6;
text-transform: uppercase;
}
/* Collapse/expand styling */
.cal-hidden { display: none; }
.cal-toggle {
font-size: 12px;
opacity: 0.6;
cursor: pointer;
text-align: center;
padding: 6px;
border-radius: 6px;
margin-top: 4px;
}
.cal-toggle:hover { opacity: 0.9; background: rgba(255,255,255,0.05); }
#cal-expand:checked ~ .cal-list .cal-extra { display: grid; }
#cal-expand:checked ~ .cal-toggle-more { display: none; }
#cal-expand:not(:checked) ~ .cal-toggle-less { display: none; }
</style>
<div class="cal-widget">
<div class="cal-meta">
<span>Next events ({{ $count }})</span>
</div>
{{ if lt $count 1 }}
<div class="cal-empty">No event in near future.</div>
{{ else }}
{{ if $hasMore }}<input type="checkbox" id="cal-expand" style="display:none;">{{ end }}
<div class="cal-list">
{{ 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" }}
<div class="cal-item{{ if $isExtra }} cal-extra cal-hidden{{ end }}">
<div>
<div class="cal-title">{{ $title }}</div>
<div class="cal-source">{{ $calendar }}</div>
</div>
<div class="cal-time">
<div class="cal-date">{{ formatTime $start "Jan 2" }}</div>
{{ if $isAllDay }}
<div class="cal-allday">Whole Day</div>
{{ else }}
<div class="cal-hour">{{ formatTime $start "15:04" }}</div>
{{ end }}
</div>
</div>
{{ end }}
</div>
{{ if $hasMore }}
<label class="cal-toggle cal-toggle-more" for="cal-expand">Show More ({{ sub $count $showCount }} további) ▼</label>
<label class="cal-toggle cal-toggle-less" for="cal-expand">Show Less ▲</label>
{{ end }}
{{ end }}
</div>
- type: rss
title: News & Feeds
limit: 15