Source code for icalendar_compatibility.location

# icalendar_compatibility
# Copyright (C) 2025  Nicco Kunzmann
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see
# <https://www.gnu.org/licenses/>.
"""Event location computation."""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Optional
from urllib.parse import quote

from icalendar import Event, vGeo, vText

GEO_MATCH = re.compile(r"^(?P<lat>[-+]?\d*\.?\d+)\s*,\s*(?P<lon>[-+]?\d*\.?\d+)$")

URL_REGEX = re.compile(
    r"https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)"
)  # see https://stackoverflow.com/a/3809435/1320237


[docs] @dataclass class LocationSpec: """Specification for event locations. Attributes: zoom : int Zoom level for geo_url and search_url geo_url : str A url template when the geo location is given. At least {lat} and {lon} are required. text_url : str A url template when the location is given as text. At leat {location} is required. Examples: >>> from icalendar_compatibility import LocationSpec >>> spec = LocationSpec.for_openstreetmap_org() """ geo_url: str text_url: str zoom: int = 16
[docs] @classmethod def for_openstreetmap_org(cls, **kw) -> LocationSpec: """Spec for https://openstreetmap.org""" return cls( geo_url="https://www.openstreetmap.org/#map={zoom}/{lat}/{lon}", text_url="https://www.openstreetmap.org/search?query={location}", **kw, )
[docs] @classmethod def for_bing_com(cls, **kw) -> LocationSpec: """Spec for https://www.bing.com/maps""" return cls( geo_url="https://www.bing.com/maps?brdr=1&cp={lat}%7E{lon}&lvl={zoom}", text_url="https://www.bing.com/maps?q={location}&lvl={zoom}", **kw, )
[docs] @classmethod def for_google_com(cls, **kw) -> LocationSpec: """Spec for https://www.google.com/maps""" return cls( geo_url="https://www.google.com/maps/@{lat},{lon},{zoom}z", text_url="https://www.google.com/maps/search/{location}", **kw, )
[docs] @classmethod def for_google_co_uk(cls, **kw) -> LocationSpec: """Spec for https://www.google.co.uk/maps""" return cls( geo_url="https://www.google.co.uk/maps/@{lat},{lon},{zoom}z", text_url="https://www.google.co.uk/maps/search/{location}", **kw, )
[docs] @classmethod def for_no_url(cls) -> LocationSpec: """Return a spec that creates empty URLs always.""" return cls(geo_url="", text_url="")
[docs] @classmethod def for_geo_url(cls) -> LocationSpec: """Spec for the geo:{lat},{lon} links. :rfc:`5870`:: geo:48.2010,16.3695 """ return cls(geo_url="geo:{lat},{lon}", text_url="")
[docs] def get_geo_url(self, *, lat: float, lon: float, zoom: Optional[int] = None) -> str: """Get the url for a geo location.""" return self.geo_url.format( lat=lat, lon=lon, zoom=self.zoom if zoom is None else zoom )
[docs] def get_text_url(self, location: str, zoom: Optional[int] = None) -> str: """Get the url for a text location.""" return self.text_url.format( location=self.quote(location), zoom=self.zoom if zoom is None else zoom )
[docs] @staticmethod def quote(string: str): """Quote a string to fit into any place in a URL. We also replace the . as .. has a special meaning. """ return quote(string, safe="").replace(".", "%2e")
[docs] class Location: """The location of an event. Attributes: text : str The text of the location. url : str The url of the event location. This considers geo information, text and more. Examples: >>> from icalendar_compatibility import Location, LocationSpec >>> from icalendar import Event >>> event_string = ''' ... BEGIN:VEVENT ... SUMMARY:Event in Mountain View with Geo link ... DTSTART:20250115T150000Z ... LOCATION:Mountain View, Santa Clara County, Kalifornien, Vereinigte Staaten von Amerika ... GEO:37.386013;-122.082932 ... END:VEVENT ... ''' >>> event = Event.from_ical(event_string) >>> location = Location(event, LocationSpec.for_bing_com()) >>> print(location.text) Mountain View, Santa Clara County, Kalifornien, Vereinigte Staaten von Amerika >>> print(location.url) https://www.bing.com/maps?brdr=1&cp=37.386013%7E-122.082932&lvl=16 """ def __init__(self, event: Event, spec: Optional[LocationSpec] = None): """Create a new location adapter. Args: event : Event The event to adapt. spec : LocationSpec The specification to use. By default we use OpenStreetMap. """ self._event = event self._spec = LocationSpec.for_openstreetmap_org() if spec is None else spec @property def raw_text(self) -> vText: r"""The raw event text of the location. :rfc:`5545`:: LOCATION:Conference Room - F123\, Bldg. 002 """ return self._event.get("LOCATION", vText("")) @property def raw_altrep(self) -> str: r"""The alternative representation according to RFC5545. :rfc:`5545`:: LOCATION;ALTREP="http://xyzcorp.com/conf-rooms/f123.vcf": Conference Room - F123\, Bldg. 002 """ return self.raw_text.params.get("ALTREP", "") @property def raw_geo(self) -> Optional[vGeo]: """The raw geo location. :rfc:`5545`:: GEO:37.386013;-122.082932 """ geo = self._event.get("GEO") if geo is not None: return geo match = GEO_MATCH.match(self.raw_text) if match: return vGeo((float(match.group("lat")), float(match.group("lon")))) return None @property def geo(self) -> Optional[vGeo]: """The geo location from latitude and longitude.""" lat = self.lat lon = self.lon if lon is not None and lat is not None: return vGeo((lat, lon)) return None @property def text(self) -> str: """The location text. Returns: str The text or an empty string if we have no location data. """ return str(self.raw_text) @property def url(self) -> str: """The location url. Returns: str The url or an empty string if we have no location data. """ if self.raw_altrep: return self.raw_altrep lon, lat = self.lon, self.lat if lon is None or lat is None: text = self.text if text == "": return "" for url in URL_REGEX.finditer(text): return url.group() return self._spec.get_text_url(location=self.text, zoom=self.zoom) return self._spec.get_geo_url(lat=lat, lon=lon, zoom=self.zoom) @property def lon(self) -> Optional[float]: """The longitude of the location. Returns: float The longitude or None if we have no location data. """ return self.raw_geo.longitude if self.raw_geo else None @property def lat(self) -> Optional[float]: """The latitude of the location. Returns: float The longitude or None if we have no location data. """ return self.raw_geo.latitude if self.raw_geo else None @property def zoom(self) -> int: """The zoom level of the location. Returns: int The zoom level of the location. """ return self._spec.zoom @property def spec(self) -> LocationSpec: """The location spec we use.""" return self._spec @spec.setter def spec(self, new_spec: LocationSpec): """Set the location spec.""" self._spec = new_spec
__all__ = ["Location", "LocationSpec"]