Serve conferences in iCalendar format.
This commit is contained in:
parent
5eab5361b2
commit
7e2b79c672
53
agenda/ical.py
Normal file
53
agenda/ical.py
Normal 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")
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
95
web_view.py
95
web_view.py
|
|
@ -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."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue