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."""
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
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"))
|
||||
|
||||
|
||||
@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
|
||||
|
|
|
|||
Loading…
Reference in a new issue