diff --git a/agenda/ical.py b/agenda/ical.py new file mode 100644 index 0000000..f180cc5 --- /dev/null +++ b/agenda/ical.py @@ -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") diff --git a/agenda/trip.py b/agenda/trip.py index b47e185..03ebcd1 100644 --- a/agenda/trip.py +++ b/agenda/trip.py @@ -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") diff --git a/web_view.py b/web_view.py index 02ade04..3ac28a5 100755 --- a/web_view.py +++ b/web_view.py @@ -3,6 +3,7 @@ """Web page to show upcoming events.""" import decimal +import hashlib import inspect import json import operator @@ -11,7 +12,7 @@ import sys import time import traceback from collections import defaultdict -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone import flask import UniAuth.auth @@ -29,7 +30,7 @@ import agenda.thespacedevs import agenda.trip import agenda.trip_schengen 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 app = flask.Flask(__name__) @@ -327,6 +328,86 @@ def build_conference_list() -> list[StrDict]: 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") def conference_list() -> str: """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") def accommodation_list() -> str: """Page showing a list of past, present and future accommodation."""