From 77ea6a8afaf0dae43c26fe5cf977bf8d4073ed16 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Fri, 14 Nov 2025 14:40:19 +0000 Subject: [PATCH] Generate iCalendar for Thunderbird, etc. --- agenda/trip.py | 312 +++++++++++++++++++++++++++++++++++++++++++++++- agenda/types.py | 37 ++++-- web_view.py | 10 ++ 3 files changed, 348 insertions(+), 11 deletions(-) diff --git a/agenda/trip.py b/agenda/trip.py index 9aad250..0aadcfb 100644 --- a/agenda/trip.py +++ b/agenda/trip.py @@ -1,16 +1,18 @@ """Trips.""" import decimal +import hashlib import os import typing -from datetime import date +from datetime import date, datetime, timedelta, timezone, tzinfo as datetime_tzinfo import flask +import pycountry import yaml from agenda import travel, trip_schengen -from agenda.types import StrDict, Trip -from agenda.utils import depart_datetime +from agenda.types import StrDict, Trip, TripElement +from agenda.utils import as_date, as_datetime, depart_datetime class Airline(typing.TypedDict, total=False): @@ -23,7 +25,7 @@ class Airline(typing.TypedDict, total=False): def load_travel(travel_type: str, plural: str, data_dir: str) -> list[StrDict]: """Read flight and train journeys.""" - items = travel.parse_yaml(plural, data_dir) + items = typing.cast(list[StrDict], travel.parse_yaml(plural, data_dir)) for item in items: item["type"] = travel_type return items @@ -509,6 +511,308 @@ def get_trip_list( return trips +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 _append_datetime_property(lines: list[str], name: str, dt_value: datetime) -> None: + """Append a datetime property preserving timezone.""" + formatted, tzid = _format_ical_datetime(dt_value) + prop_name = f"{name};TZID={tzid}" if tzid else name + _append_ical_property(lines, prop_name, formatted) + + +def _ensure_timezone(dt_value: datetime) -> datetime: + """Ensure datetimes are timezone-aware.""" + if dt_value.tzinfo is None or dt_value.tzinfo.utcoffset(dt_value) is None: + return dt_value.replace(tzinfo=timezone.utc) + return dt_value + + +def _tzid_from_tzinfo(tzinfo_obj: datetime_tzinfo, dt_value: datetime) -> str | None: + """Return TZID string for timezone-aware datetime.""" + for attr in ("zone", "key"): + tzid = getattr(tzinfo_obj, attr, None) + if isinstance(tzid, str): + return tzid + tzname = tzinfo_obj.tzname(dt_value) + if isinstance(tzname, str) and tzname.upper() not in {"UTC", "GMT"}: + return tzname + return None + + +def _format_ical_datetime(dt_value: datetime) -> tuple[str, str | None]: + """Format datetime objects, preserving timezone when available.""" + dt_value = _ensure_timezone(dt_value) + tzinfo = dt_value.tzinfo + assert tzinfo + tzid = _tzid_from_tzinfo(tzinfo, dt_value) + if tzid: + local_dt = dt_value.astimezone(tzinfo).replace(tzinfo=None) + return local_dt.strftime("%Y%m%dT%H%M%S"), tzid + utc_dt = dt_value.astimezone(timezone.utc) + return utc_dt.strftime("%Y%m%dT%H%M%SZ"), None + + +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) + end_dt = as_datetime(element.end_time) if element.end_time else None + if end_dt is None or end_dt <= start_dt: + end_dt = start_dt + DEFAULT_EVENT_DURATION + return _ensure_timezone(start_dt), _ensure_timezone(end_dt) + + +def _event_all_day_dates(element: TripElement) -> tuple[date, date]: + """Return start and exclusive end dates for all-day events.""" + start_date = as_date(element.start_time) + end_source = element.end_time or element.start_time + end_date = as_date(end_source) + timedelta(days=1) + return start_date, end_date + + +def _trip_element_location(element: TripElement) -> str | None: + """Derive a location string for the element.""" + start_loc: str | None = element.start_loc + end_loc: str | None = element.end_loc + if start_loc and end_loc: + return f"{start_loc} → {end_loc}" + if start_loc: + return start_loc + if end_loc: + return end_loc + return None + + +def _trip_element_description(trip: Trip, element: TripElement) -> str: + """Build a textual description for the element.""" + lines = [ + f"Trip: {trip.title}", + f"Type: {element.element_type}", + ] + if element.element_type == "conference": + location_label = element.detail.get("location") + venue_label = element.detail.get("venue") or location_label + if place := _format_place( + location_label, + element.start_country, + element.element_type, + label_prefix="Location", + skip_country_if_in_label=True, + ): + lines.append(place) + if place := _format_place( + venue_label, + element.end_country, + element.element_type, + label_prefix="Venue", + ): + lines.append(place) + else: + if place := _format_place( + element.start_loc, + element.start_country, + element.element_type, + label_prefix="From", + ): + lines.append(place) + if place := _format_place( + element.end_loc, + element.end_country, + element.element_type, + label_prefix="To", + ): + lines.append(place) + lines.append(f"Trip start date: {_format_trip_start_date(trip)}") + return "\n".join(lines) + + +def _format_place( + label: str | None, + country: pycountry.db.Country | None, + element_type: str, + label_prefix: str | None = None, + skip_country_if_in_label: bool = False, +) -> str | None: + """Combine location label and country into a single string.""" + parts: list[str] = [] + formatted_label = label + if element_type == "train" and label: + stripped = label.strip() + if not stripped.lower().endswith("station"): + formatted_label = f"{stripped} station" + else: + formatted_label = stripped + if formatted_label: + parts.append(formatted_label) + if country: + include_country = True + if (element_type == "train" or skip_country_if_in_label) and formatted_label: + include_country = country.name.lower() not in formatted_label.lower() + if include_country: + parts.append(country.name) + if not parts: + return None + if label_prefix: + return f"{label_prefix}: {', '.join(parts)}" + return ", ".join(parts) + + +def _format_trip_start_date(trip: Trip) -> str: + """Return trip start date in UK human-readable form with day name.""" + day_name = trip.start.strftime("%A") + month_year = trip.start.strftime("%B %Y") + return f"{day_name} {trip.start.day} {month_year}" + + +def _flight_iata(detail: StrDict, key: str) -> str | None: + """Extract an IATA code from flight detail.""" + airport = typing.cast(StrDict | None, detail.get(key)) + if not airport: + return None + return typing.cast(str | None, airport.get("iata")) + + +def _flight_label(detail: StrDict) -> str | None: + """Return a compact label for flight elements.""" + from_code = _flight_iata(detail, "from_airport") + to_code = _flight_iata(detail, "to_airport") + if from_code and to_code: + return f"{from_code}→{to_code}" + if from_code: + return from_code + if to_code: + return to_code + return None + + +def _trip_element_label(element: TripElement) -> str: + """Return a succinct label describing the element.""" + if element.element_type == "flight": + if label := _flight_label(element.detail): + return label + if element.element_type == "conference": + conference_name = element.detail.get("name") + if isinstance(conference_name, str): + return conference_name + + start_loc = element.start_loc + end_loc = element.end_loc + if isinstance(start_loc, str) and isinstance(end_loc, str): + return f"{start_loc} → {end_loc}" + if isinstance(start_loc, str): + return start_loc + if isinstance(end_loc, str): + return end_loc + return element.title + + +def _trip_element_summary(trip: Trip, element: TripElement) -> str: + """Build the calendar summary text.""" + label = _trip_element_label(element) + if element.element_type == "conference": + return f"{element.element_type}: {label}" + return f"{element.element_type}: {label} [{trip.title}]" + + +def _trip_element_uid(trip: Trip, element: TripElement, index: int) -> str: + """Generate a deterministic UID for calendar clients.""" + raw = "|".join( + [ + trip.start.isoformat(), + trip.title, + element.element_type, + element.title, + str(index), + ] + ) + digest = hashlib.sha1(raw.encode("utf-8")).hexdigest() + return f"trip-{digest}@agenda-codex" + + +def build_trip_ical(trips: list[Trip]) -> bytes: + """Return an iCal feed containing all trip elements.""" + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Agenda Codex//Trips//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "X-WR-CALNAME:Trips", + ] + generated = datetime.now(tz=timezone.utc) + + 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_datetime_property(lines, "DTSTAMP", 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) + ) + _append_ical_property( + lines, "DTEND;VALUE=DATE", _format_ical_date(end_date) + ) + else: + start_dt, end_dt = _event_datetimes(element) + _append_datetime_property(lines, "DTSTART", start_dt) + _append_datetime_property(lines, "DTEND", end_dt) + summary = _escape_ical_text(_trip_element_summary(trip, element)) + _append_ical_property(lines, "SUMMARY", summary) + + description = _escape_ical_text(_trip_element_description(trip, element)) + _append_ical_property(lines, "DESCRIPTION", description) + + if location := _trip_element_location(element): + _append_ical_property(lines, "LOCATION", _escape_ical_text(location)) + + lines.append("END:VEVENT") + + lines.append("END:VCALENDAR") + ical_text = "\r\n".join(lines) + "\r\n" + return ical_text.encode("utf-8") + + def get_current_trip(today: date) -> Trip | None: """Get current trip.""" trip_list = get_trip_list(route_distances=None) diff --git a/agenda/types.py b/agenda/types.py index 6ac09cb..fcf9b77 100644 --- a/agenda/types.py +++ b/agenda/types.py @@ -33,6 +33,7 @@ class TripElement: end_loc: str | None = None start_country: Country | None = None end_country: Country | None = None + all_day: bool = False def get_emoji(self) -> str | None: """Emoji for trip element.""" @@ -220,18 +221,22 @@ class Trip: @property def countries_str(self) -> str: """List of countries visited on this trip.""" - return format_list_with_ampersand( - [f"{c.name} {c.flag}" for c in self.countries] + return typing.cast( + str, + format_list_with_ampersand([f"{c.name} {c.flag}" for c in self.countries]), ) @property def locations_str(self) -> str: """List of countries visited on this trip.""" - return format_list_with_ampersand( - [ - f"{location} ({c.name})" + (f" {c.flag}" if self.show_flags else "") - for location, c in self.locations() - ] + return typing.cast( + str, + format_list_with_ampersand( + [ + f"{location} ({c.name})" + (f" {c.flag}" if self.show_flags else "") + for location, c in self.locations() + ] + ), ) @property @@ -315,6 +320,24 @@ class Trip: elements.append(end) + for item in self.conferences: + location_label = item.get("venue") or item.get("location") or item["name"] + country = agenda.get_country(item.get("country")) + + elements.append( + TripElement( + start_time=item["start"], + end_time=item["end"], + title=item["name"], + detail=item, + element_type="conference", + start_loc=location_label, + start_country=country, + end_country=country, + all_day=True, + ) + ) + for item in self.travel: if item["type"] == "flight": name = ( diff --git a/web_view.py b/web_view.py index 5fea6b9..02ade04 100755 --- a/web_view.py +++ b/web_view.py @@ -423,6 +423,16 @@ def trip_list() -> werkzeug.Response: return flask.redirect(flask.url_for("trip_future_list")) +@app.route("/trip/ical") +def trip_ical() -> werkzeug.Response: + """Return trip components in iCal format.""" + trips = get_trip_list() + ical_data = agenda.trip.build_trip_ical(trips) + response = flask.Response(ical_data, mimetype="text/calendar") + response.headers["Content-Disposition"] = "inline; filename=trips.ics" + return response + + def calc_total_distance(trips: list[Trip]) -> float: """Total distance for trips.""" total = 0.0