From f41a819de966290a64a26255da370733687ce4dd Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Wed, 22 Jul 2026 08:48:47 -0700 Subject: [PATCH] Add lecture calendar subscription --- frontend/src/pages/lectures/Lectures.tsx | 19 +++- kernelboard/api/events.py | 137 ++++++++++++++++++++++- tests/api/test_events_api.py | 99 ++++++++++++++++ 3 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 tests/api/test_events_api.py diff --git a/frontend/src/pages/lectures/Lectures.tsx b/frontend/src/pages/lectures/Lectures.tsx index aa1b21b1..a5c86060 100644 --- a/frontend/src/pages/lectures/Lectures.tsx +++ b/frontend/src/pages/lectures/Lectures.tsx @@ -1,10 +1,16 @@ -import { Box, Typography, Link, Chip } from "@mui/material"; +import { Box, Typography, Link, Chip, Button } from "@mui/material"; import type { Theme } from "@mui/material/styles"; import { useEffect, useState } from "react"; import { fetchEvents } from "../../api/api"; import type { DiscordEvent } from "../../api/api"; import Loading from "../../components/common/loading"; +const LECTURE_CALENDAR_FEED_URL = + "https://www.gpumode.com/api/events/calendar.ics"; +const GOOGLE_CALENDAR_SUBSCRIBE_URL = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent( + LECTURE_CALENDAR_FEED_URL, +)}`; + const styles = { container: { maxWidth: "900px", @@ -397,6 +403,17 @@ export default function Lectures() { . + {loading ? ( ) : error ? ( diff --git a/kernelboard/api/events.py b/kernelboard/api/events.py index 02f63a2a..26429521 100644 --- a/kernelboard/api/events.py +++ b/kernelboard/api/events.py @@ -1,10 +1,11 @@ import logging import os import time +from datetime import datetime, timedelta, timezone from http import HTTPStatus import requests -from flask import Blueprint +from flask import Blueprint, Response from kernelboard.lib.status_code import http_error, http_success @@ -18,6 +19,119 @@ "timestamp": 0, } CACHE_TTL_SECONDS = 300 # 5 minutes +CALENDAR_NAME = "GPU MODE Upcoming Lectures" +CALENDAR_FILENAME = "gpu-mode-upcoming-lectures.ics" + + +def _parse_datetime(value): + """Parse an ISO 8601 value and normalize it to UTC.""" + if not isinstance(value, str) or not value: + return None + + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + + return parsed.astimezone(timezone.utc) + + +def _escape_ical_text(value): + """Escape a value used by an iCalendar TEXT property.""" + normalized = str(value or "").replace("\r\n", "\n").replace("\r", "\n") + return ( + normalized.replace("\\", "\\\\") + .replace("\n", "\\n") + .replace(";", "\\;") + .replace(",", "\\,") + ) + + +def _fold_ical_line(line): + """Fold an iCalendar content line at 75 UTF-8 octets.""" + remaining = line.encode("utf-8") + folded = [] + first_line = True + + while remaining: + limit = 75 if first_line else 74 + split_at = min(limit, len(remaining)) + + # Do not split in the middle of a multi-byte UTF-8 character. + while split_at < len(remaining) and remaining[split_at] & 0xC0 == 0x80: + split_at -= 1 + + chunk = remaining[:split_at].decode("utf-8") + folded.append(chunk if first_line else f" {chunk}") + remaining = remaining[split_at:] + first_line = False + + return "\r\n".join(folded) + + +def _render_icalendar(events, now=None): + """Render upcoming Discord events as an RFC 5545 calendar feed.""" + now = now or datetime.now(timezone.utc) + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + now = now.astimezone(timezone.utc) + timestamp = now.strftime("%Y%m%dT%H%M%SZ") + + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//GPU MODE//Upcoming Lectures//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + f"X-WR-CALNAME:{_escape_ical_text(CALENDAR_NAME)}", + "REFRESH-INTERVAL;VALUE=DURATION:PT5M", + "X-PUBLISHED-TTL:PT5M", + ] + + for event in events: + event_id = event.get("id") + start = _parse_datetime(event.get("scheduled_start_time")) + scheduled_end = _parse_datetime(event.get("scheduled_end_time")) + + if not event_id or start is None: + continue + + # Match the frontend: an event remains upcoming until its end, when set. + if (scheduled_end or start) < now: + continue + + end = scheduled_end + if end is None or end <= start: + end = start + timedelta(hours=1) + + event_url = str(event.get("event_url") or "").replace("\r", "").replace("\n", "") + description = str(event.get("description") or "").strip() + if event_url: + description = "\n\n".join( + part for part in (description, f"View on Discord: {event_url}") if part + ) + + lines.extend([ + "BEGIN:VEVENT", + f"UID:discord-{_escape_ical_text(event_id)}@gpumode.com", + f"DTSTAMP:{timestamp}", + f"DTSTART:{start.strftime('%Y%m%dT%H%M%SZ')}", + f"DTEND:{end.strftime('%Y%m%dT%H%M%SZ')}", + f"SUMMARY:{_escape_ical_text(event.get('name') or 'GPU MODE Lecture')}", + f"DESCRIPTION:{_escape_ical_text(description)}", + "LOCATION:GPU MODE Discord", + "STATUS:CONFIRMED", + "TRANSP:TRANSPARENT", + ]) + if event_url: + lines.append(f"URL:{event_url}") + lines.append("END:VEVENT") + + lines.append("END:VCALENDAR") + return "\r\n".join(_fold_ical_line(line) for line in lines) + "\r\n" def _get_discord_events(): @@ -89,3 +203,24 @@ def list_events(): status_code=HTTPStatus.INTERNAL_SERVER_ERROR, message=f"Internal server error: {str(e)}", ) + + +@events_bp.route("/calendar.ics", methods=["GET"]) +def calendar_feed(): + """Return upcoming Discord scheduled events as an iCalendar feed.""" + try: + calendar = _render_icalendar(_get_discord_events()) + return Response( + calendar, + content_type="text/calendar; charset=utf-8", + headers={ + "Cache-Control": f"public, max-age={CACHE_TTL_SECONDS}", + "Content-Disposition": f'inline; filename="{CALENDAR_FILENAME}"', + }, + ) + except Exception as e: + logger.error(f"Error rendering calendar feed: {e}") + return http_error( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + message=f"Internal server error: {str(e)}", + ) diff --git a/tests/api/test_events_api.py b/tests/api/test_events_api.py new file mode 100644 index 00000000..2d007ac7 --- /dev/null +++ b/tests/api/test_events_api.py @@ -0,0 +1,99 @@ +from datetime import datetime, timezone +from unittest.mock import patch + +from flask import Blueprint, Flask + +from kernelboard.api.events import _render_icalendar, events_bp + + +def _events_client(): + app = Flask(__name__) + api = Blueprint("events_test_api", __name__, url_prefix="/api") + api.register_blueprint(events_bp) + app.register_blueprint(api) + return app.test_client() + + +def test_calendar_feed_returns_upcoming_events(): + events = [ + { + "id": "123456", + "name": "Fast kernels, from A;B", + "description": "Line one\nLine two", + "scheduled_start_time": "2099-07-27T18:00:00+00:00", + "scheduled_end_time": "2099-07-27T19:20:00+00:00", + "event_url": "https://discord.com/events/1/123456", + } + ] + + with patch("kernelboard.api.events._get_discord_events", return_value=events): + response = _events_client().get("/api/events/calendar.ics") + + calendar = response.get_data(as_text=True) + assert response.status_code == 200 + assert response.content_type == "text/calendar; charset=utf-8" + assert response.headers["Content-Disposition"] == ( + 'inline; filename="gpu-mode-upcoming-lectures.ics"' + ) + assert "UID:discord-123456@gpumode.com\r\n" in calendar + assert "DTSTART:20990727T180000Z\r\n" in calendar + assert "DTEND:20990727T192000Z\r\n" in calendar + assert "SUMMARY:Fast kernels\\, from A\\;B\r\n" in calendar + assert "DESCRIPTION:Line one\\nLine two\\n\\nView on Discord: https://discord.com" in calendar + assert "TRANSP:TRANSPARENT\r\n" in calendar + + +def test_calendar_renderer_filters_past_and_defaults_missing_end_time(): + events = [ + { + "id": "past", + "name": "Past lecture", + "scheduled_start_time": "2026-07-20T18:00:00+00:00", + "scheduled_end_time": "2026-07-20T19:00:00+00:00", + }, + { + "id": "current", + "name": "Current lecture", + "scheduled_start_time": "2026-07-22T18:00:00+00:00", + "scheduled_end_time": "2026-07-22T19:00:00+00:00", + }, + { + "id": "future", + "name": "Future lecture", + "scheduled_start_time": "2026-07-23T18:00:00Z", + "scheduled_end_time": None, + }, + { + "id": "invalid", + "name": "Invalid lecture", + "scheduled_start_time": "not-a-date", + }, + ] + + calendar = _render_icalendar( + events, + now=datetime(2026, 7, 22, 18, 30, tzinfo=timezone.utc), + ) + + assert "Past lecture" not in calendar + assert "Invalid lecture" not in calendar + assert "SUMMARY:Current lecture\r\n" in calendar + assert "SUMMARY:Future lecture\r\n" in calendar + assert "DTSTART:20260723T180000Z\r\n" in calendar + assert "DTEND:20260723T190000Z\r\n" in calendar + + +def test_calendar_renderer_folds_long_utf8_lines_to_75_octets(): + calendar = _render_icalendar( + [ + { + "id": "long-title", + "name": "GPU kernels 🚀 " * 20, + "scheduled_start_time": "2099-01-01T10:00:00Z", + "scheduled_end_time": "2099-01-01T11:00:00Z", + } + ], + now=datetime(2026, 7, 22, tzinfo=timezone.utc), + ) + + assert all(len(line.encode("utf-8")) <= 75 for line in calendar.split("\r\n"))