Generate iCalendar for Thunderbird, etc.

This commit is contained in:
Edward Betts 2025-11-14 14:40:19 +00:00
parent 97933cfe8f
commit 77ea6a8afa
3 changed files with 348 additions and 11 deletions

View file

@ -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)

View file

@ -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(
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 = (

View file

@ -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