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 yaml
from agenda import travel, trip_schengen
from agenda import ical, travel, trip_schengen
from agenda.types import StrDict, Trip, TripElement
from agenda.utils import as_date, as_datetime, depart_datetime
@ -514,40 +514,6 @@ def get_trip_list(
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:
"""Ensure datetimes are timezone-aware in UTC."""
if dt_value.tzinfo is None:
@ -555,16 +521,6 @@ def _ensure_utc(dt_value: datetime) -> datetime:
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]:
"""Return start and end datetimes for a trip element."""
start_dt = as_datetime(element.start_time)
@ -756,28 +712,30 @@ def build_trip_ical(trips: list[Trip]) -> bytes:
for trip in trips:
for index, element in enumerate(trip.elements()):
lines.append("BEGIN:VEVENT")
_append_ical_property(lines, "UID", _trip_element_uid(trip, element, index))
_append_ical_property(lines, "DTSTAMP", _format_ical_datetime(generated))
ical.append_property(lines, "UID", _trip_element_uid(trip, element, index))
ical.append_property(lines, "DTSTAMP", ical.format_datetime_utc(generated))
if element.all_day:
start_date, end_date = _event_all_day_dates(element)
_append_ical_property(
lines, "DTSTART;VALUE=DATE", _format_ical_date(start_date)
ical.append_property(
lines, "DTSTART;VALUE=DATE", ical.format_date(start_date)
)
_append_ical_property(
lines, "DTEND;VALUE=DATE", _format_ical_date(end_date)
ical.append_property(
lines, "DTEND;VALUE=DATE", ical.format_date(end_date)
)
else:
start_dt, end_dt = _event_datetimes(element)
_append_ical_property(lines, "DTSTART", _format_ical_datetime(start_dt))
_append_ical_property(lines, "DTEND", _format_ical_datetime(end_dt))
summary = _escape_ical_text(_trip_element_summary(trip, element))
_append_ical_property(lines, "SUMMARY", summary)
ical.append_property(
lines, "DTSTART", ical.format_datetime_utc(start_dt)
)
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))
_append_ical_property(lines, "DESCRIPTION", description)
description = ical.escape_text(_trip_element_description(trip, element))
ical.append_property(lines, "DESCRIPTION", description)
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")