Serve conferences in iCalendar format.

This commit is contained in:
Edward Betts 2025-11-14 15:40:05 +00:00
parent 5eab5361b2
commit 7e2b79c672
3 changed files with 162 additions and 60 deletions

53
agenda/ical.py Normal file
View file

@ -0,0 +1,53 @@
"""Shared helpers for generating iCalendar feeds."""
from __future__ import annotations
from datetime import date, datetime, timezone
from typing import Iterable
def escape_text(value: str) -> str:
"""Escape text for safer ICS output."""
return (
value.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\n", "\\n")
)
def _fold_line(value: str) -> Iterable[str]:
"""Yield RFC5545 folded lines."""
if len(value) <= 75:
yield value
return
remaining = value
first = True
while remaining:
segment = remaining[:75]
remaining = remaining[75:]
if not first:
segment = " " + segment
yield segment
first = False
def append_property(lines: list[str], name: str, value: str) -> None:
"""Append a folded property line to the ICS output."""
for line in _fold_line(f"{name}:{value}"):
lines.append(line)
def format_datetime_utc(dt_value: datetime) -> str:
"""Return datetime formatted in UTC for ICS."""
if dt_value.tzinfo is None:
dt_value = dt_value.replace(tzinfo=timezone.utc)
else:
dt_value = dt_value.astimezone(timezone.utc)
return dt_value.strftime("%Y%m%dT%H%M%SZ")
def format_date(date_value: date) -> str:
"""Return date formatted for all-day ICS events."""
return date_value.strftime("%Y%m%d")

View file

@ -10,7 +10,7 @@ import flask
import pycountry import pycountry
import yaml import yaml
from agenda import travel, trip_schengen from agenda import ical, travel, trip_schengen
from agenda.types import StrDict, Trip, TripElement from agenda.types import StrDict, Trip, TripElement
from agenda.utils import as_date, as_datetime, depart_datetime from agenda.utils import as_date, as_datetime, depart_datetime
@ -514,40 +514,6 @@ def get_trip_list(
DEFAULT_EVENT_DURATION = timedelta(hours=1) DEFAULT_EVENT_DURATION = timedelta(hours=1)
def _escape_ical_text(value: str) -> str:
"""Escape special characters for iCal text fields."""
return (
value.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\n", "\\n")
)
def _fold_ical_line(value: str) -> list[str]:
"""Fold iCal lines at 75 characters as per RFC5545."""
if len(value) <= 75:
return [value]
folded: list[str] = []
chunk = value
first = True
while chunk:
segment = chunk[:75]
chunk = chunk[75:]
if not first:
segment = " " + segment
folded.append(segment)
first = False
return folded
def _append_ical_property(lines: list[str], name: str, value: str) -> None:
"""Append a property to the iCal output with proper folding."""
for line in _fold_ical_line(f"{name}:{value}"):
lines.append(line)
def _ensure_utc(dt_value: datetime) -> datetime: def _ensure_utc(dt_value: datetime) -> datetime:
"""Ensure datetimes are timezone-aware in UTC.""" """Ensure datetimes are timezone-aware in UTC."""
if dt_value.tzinfo is None: if dt_value.tzinfo is None:
@ -555,16 +521,6 @@ def _ensure_utc(dt_value: datetime) -> datetime:
return dt_value.astimezone(timezone.utc) return dt_value.astimezone(timezone.utc)
def _format_ical_datetime(dt_value: datetime) -> str:
"""Format datetime objects using UTC and RFC5545 format."""
return _ensure_utc(dt_value).strftime("%Y%m%dT%H%M%SZ")
def _format_ical_date(date_value: date) -> str:
"""Format date objects for all-day events."""
return date_value.strftime("%Y%m%d")
def _event_datetimes(element: TripElement) -> tuple[datetime, datetime]: def _event_datetimes(element: TripElement) -> tuple[datetime, datetime]:
"""Return start and end datetimes for a trip element.""" """Return start and end datetimes for a trip element."""
start_dt = as_datetime(element.start_time) start_dt = as_datetime(element.start_time)
@ -756,28 +712,30 @@ def build_trip_ical(trips: list[Trip]) -> bytes:
for trip in trips: for trip in trips:
for index, element in enumerate(trip.elements()): for index, element in enumerate(trip.elements()):
lines.append("BEGIN:VEVENT") lines.append("BEGIN:VEVENT")
_append_ical_property(lines, "UID", _trip_element_uid(trip, element, index)) ical.append_property(lines, "UID", _trip_element_uid(trip, element, index))
_append_ical_property(lines, "DTSTAMP", _format_ical_datetime(generated)) ical.append_property(lines, "DTSTAMP", ical.format_datetime_utc(generated))
if element.all_day: if element.all_day:
start_date, end_date = _event_all_day_dates(element) start_date, end_date = _event_all_day_dates(element)
_append_ical_property( ical.append_property(
lines, "DTSTART;VALUE=DATE", _format_ical_date(start_date) lines, "DTSTART;VALUE=DATE", ical.format_date(start_date)
) )
_append_ical_property( ical.append_property(
lines, "DTEND;VALUE=DATE", _format_ical_date(end_date) lines, "DTEND;VALUE=DATE", ical.format_date(end_date)
) )
else: else:
start_dt, end_dt = _event_datetimes(element) start_dt, end_dt = _event_datetimes(element)
_append_ical_property(lines, "DTSTART", _format_ical_datetime(start_dt)) ical.append_property(
_append_ical_property(lines, "DTEND", _format_ical_datetime(end_dt)) lines, "DTSTART", ical.format_datetime_utc(start_dt)
summary = _escape_ical_text(_trip_element_summary(trip, element)) )
_append_ical_property(lines, "SUMMARY", summary) ical.append_property(lines, "DTEND", ical.format_datetime_utc(end_dt))
summary = ical.escape_text(_trip_element_summary(trip, element))
ical.append_property(lines, "SUMMARY", summary)
description = _escape_ical_text(_trip_element_description(trip, element)) description = ical.escape_text(_trip_element_description(trip, element))
_append_ical_property(lines, "DESCRIPTION", description) ical.append_property(lines, "DESCRIPTION", description)
if location := _trip_element_location(element): if location := _trip_element_location(element):
_append_ical_property(lines, "LOCATION", _escape_ical_text(location)) ical.append_property(lines, "LOCATION", ical.escape_text(location))
lines.append("END:VEVENT") lines.append("END:VEVENT")

View file

@ -3,6 +3,7 @@
"""Web page to show upcoming events.""" """Web page to show upcoming events."""
import decimal import decimal
import hashlib
import inspect import inspect
import json import json
import operator import operator
@ -11,7 +12,7 @@ import sys
import time import time
import traceback import traceback
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta, timezone
import flask import flask
import UniAuth.auth import UniAuth.auth
@ -29,7 +30,7 @@ import agenda.thespacedevs
import agenda.trip import agenda.trip
import agenda.trip_schengen import agenda.trip_schengen
import agenda.utils import agenda.utils
from agenda import calendar, format_list_with_ampersand, travel, uk_tz from agenda import ical, calendar, format_list_with_ampersand, travel, uk_tz
from agenda.types import StrDict, Trip from agenda.types import StrDict, Trip
app = flask.Flask(__name__) app = flask.Flask(__name__)
@ -327,6 +328,86 @@ def build_conference_list() -> list[StrDict]:
return items return items
def _conference_uid(conf: StrDict) -> str:
"""Generate deterministic UID for conference events."""
start = agenda.utils.as_date(conf["start"])
raw = f"conference|{start.isoformat()}|{conf.get('name','unknown')}"
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return f"conference-{digest}@agenda-codex"
def _conference_location(conf: StrDict) -> str | None:
"""Build conference location string."""
parts: list[str] = []
venue = conf.get("venue")
location = conf.get("location")
if isinstance(venue, str) and venue.strip():
parts.append(venue.strip())
if isinstance(location, str) and location.strip():
parts.append(location.strip())
if country_code := conf.get("country"):
country = agenda.get_country(country_code)
if country:
parts.append(country.name)
return ", ".join(parts) if parts else None
def _conference_description(conf: StrDict) -> str:
"""Build textual description for conferences."""
lines: list[str] = []
if topic := conf.get("topic"):
lines.append(f"Topic: {topic}")
if venue := conf.get("venue"):
lines.append(f"Venue: {venue}")
if address := conf.get("address"):
lines.append(f"Address: {address}")
if url := conf.get("url"):
lines.append(f"URL: {url}")
status_bits: list[str] = []
if conf.get("going"):
status_bits.append("attending")
if conf.get("speaking"):
status_bits.append("speaking")
if status_bits:
lines.append(f"Status: {', '.join(status_bits)}")
return "\n".join(lines) if lines else "Conference"
def build_conference_ical(items: list[StrDict]) -> bytes:
"""Build iCalendar feed for all conferences."""
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Agenda Codex//Conferences//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:Conferences",
]
generated = datetime.now(tz=timezone.utc)
for conf in items:
start_date = agenda.utils.as_date(conf["start"])
end_date = agenda.utils.as_date(conf["end"])
end_exclusive = end_date + timedelta(days=1)
lines.append("BEGIN:VEVENT")
ical.append_property(lines, "UID", _conference_uid(conf))
ical.append_property(lines, "DTSTAMP", ical.format_datetime_utc(generated))
ical.append_property(lines, "DTSTART;VALUE=DATE", ical.format_date(start_date))
ical.append_property(lines, "DTEND;VALUE=DATE", ical.format_date(end_exclusive))
summary = ical.escape_text(f"conference: {conf['name']}")
ical.append_property(lines, "SUMMARY", summary)
description = ical.escape_text(_conference_description(conf))
ical.append_property(lines, "DESCRIPTION", description)
if location := _conference_location(conf):
ical.append_property(lines, "LOCATION", ical.escape_text(location))
lines.append("END:VEVENT")
lines.append("END:VCALENDAR")
ical_text = "\r\n".join(lines) + "\r\n"
return ical_text.encode("utf-8")
@app.route("/conference") @app.route("/conference")
def conference_list() -> str: def conference_list() -> str:
"""Page showing a list of conferences.""" """Page showing a list of conferences."""
@ -363,6 +444,16 @@ def past_conference_list() -> str:
) )
@app.route("/conference/ical")
def conference_ical() -> werkzeug.Response:
"""Return all conferences as an iCalendar feed."""
items = build_conference_list()
ical_data = build_conference_ical(items)
response = flask.Response(ical_data, mimetype="text/calendar")
response.headers["Content-Disposition"] = "inline; filename=conferences.ics"
return response
@app.route("/accommodation") @app.route("/accommodation")
def accommodation_list() -> str: def accommodation_list() -> str:
"""Page showing a list of past, present and future accommodation.""" """Page showing a list of past, present and future accommodation."""