Generate iCalendar for Thunderbird, etc.
This commit is contained in:
parent
97933cfe8f
commit
77ea6a8afa
312
agenda/trip.py
312
agenda/trip.py
|
|
@ -1,16 +1,18 @@
|
||||||
"""Trips."""
|
"""Trips."""
|
||||||
|
|
||||||
import decimal
|
import decimal
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import typing
|
import typing
|
||||||
from datetime import date
|
from datetime import date, datetime, timedelta, timezone, tzinfo as datetime_tzinfo
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
|
import pycountry
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from agenda import travel, trip_schengen
|
from agenda import travel, trip_schengen
|
||||||
from agenda.types import StrDict, Trip
|
from agenda.types import StrDict, Trip, TripElement
|
||||||
from agenda.utils import depart_datetime
|
from agenda.utils import as_date, as_datetime, depart_datetime
|
||||||
|
|
||||||
|
|
||||||
class Airline(typing.TypedDict, total=False):
|
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]:
|
def load_travel(travel_type: str, plural: str, data_dir: str) -> list[StrDict]:
|
||||||
"""Read flight and train journeys."""
|
"""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:
|
for item in items:
|
||||||
item["type"] = travel_type
|
item["type"] = travel_type
|
||||||
return items
|
return items
|
||||||
|
|
@ -509,6 +511,308 @@ def get_trip_list(
|
||||||
return trips
|
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:
|
def get_current_trip(today: date) -> Trip | None:
|
||||||
"""Get current trip."""
|
"""Get current trip."""
|
||||||
trip_list = get_trip_list(route_distances=None)
|
trip_list = get_trip_list(route_distances=None)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ class TripElement:
|
||||||
end_loc: str | None = None
|
end_loc: str | None = None
|
||||||
start_country: Country | None = None
|
start_country: Country | None = None
|
||||||
end_country: Country | None = None
|
end_country: Country | None = None
|
||||||
|
all_day: bool = False
|
||||||
|
|
||||||
def get_emoji(self) -> str | None:
|
def get_emoji(self) -> str | None:
|
||||||
"""Emoji for trip element."""
|
"""Emoji for trip element."""
|
||||||
|
|
@ -220,18 +221,22 @@ class Trip:
|
||||||
@property
|
@property
|
||||||
def countries_str(self) -> str:
|
def countries_str(self) -> str:
|
||||||
"""List of countries visited on this trip."""
|
"""List of countries visited on this trip."""
|
||||||
return format_list_with_ampersand(
|
return typing.cast(
|
||||||
[f"{c.name} {c.flag}" for c in self.countries]
|
str,
|
||||||
|
format_list_with_ampersand([f"{c.name} {c.flag}" for c in self.countries]),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def locations_str(self) -> str:
|
def locations_str(self) -> str:
|
||||||
"""List of countries visited on this trip."""
|
"""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 "")
|
f"{location} ({c.name})" + (f" {c.flag}" if self.show_flags else "")
|
||||||
for location, c in self.locations()
|
for location, c in self.locations()
|
||||||
]
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -315,6 +320,24 @@ class Trip:
|
||||||
|
|
||||||
elements.append(end)
|
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:
|
for item in self.travel:
|
||||||
if item["type"] == "flight":
|
if item["type"] == "flight":
|
||||||
name = (
|
name = (
|
||||||
|
|
|
||||||
10
web_view.py
10
web_view.py
|
|
@ -423,6 +423,16 @@ def trip_list() -> werkzeug.Response:
|
||||||
return flask.redirect(flask.url_for("trip_future_list"))
|
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:
|
def calc_total_distance(trips: list[Trip]) -> float:
|
||||||
"""Total distance for trips."""
|
"""Total distance for trips."""
|
||||||
total = 0.0
|
total = 0.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue