From ca599eab7a657ac9994516bc776ec1f77c5664ab Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Thu, 8 May 2025 10:02:22 +0200 Subject: [PATCH] Updated ics_calendar to restore compatibility with HA --- configuration.yaml | 1 - custom_components/ics_calendar/__init__.py | 189 ++++++++-- custom_components/ics_calendar/calendar.py | 188 +++++++--- .../ics_calendar/calendardata.py | 226 ++++++------ custom_components/ics_calendar/config_flow.py | 330 ++++++++++++++++++ custom_components/ics_calendar/const.py | 27 +- custom_components/ics_calendar/filter.py | 7 +- custom_components/ics_calendar/getparser.py | 27 ++ .../ics_calendar/icalendarparser.py | 39 +-- custom_components/ics_calendar/manifest.json | 6 +- custom_components/ics_calendar/parserevent.py | 20 ++ .../ics_calendar/parsers/parser_ics.py | 17 +- .../ics_calendar/parsers/parser_rie.py | 23 +- custom_components/ics_calendar/strings.json | 77 ++++ .../ics_calendar/translations/de.json | 76 ++++ .../ics_calendar/translations/en.json | 77 ++++ .../ics_calendar/translations/es.json | 77 ++++ .../ics_calendar/translations/fr.json | 76 ++++ .../ics_calendar/translations/pt-br.json | 77 ++++ custom_components/ics_calendar/utility.py | 3 +- custom_components/ics_calendar_old.tar.bz2 | Bin 0 -> 27600 bytes 21 files changed, 1329 insertions(+), 234 deletions(-) create mode 100644 custom_components/ics_calendar/config_flow.py create mode 100644 custom_components/ics_calendar/getparser.py create mode 100644 custom_components/ics_calendar/parserevent.py create mode 100644 custom_components/ics_calendar/strings.json create mode 100644 custom_components/ics_calendar/translations/de.json create mode 100644 custom_components/ics_calendar/translations/en.json create mode 100644 custom_components/ics_calendar/translations/es.json create mode 100644 custom_components/ics_calendar/translations/fr.json create mode 100644 custom_components/ics_calendar/translations/pt-br.json create mode 100644 custom_components/ics_calendar_old.tar.bz2 diff --git a/configuration.yaml b/configuration.yaml index 598f9d4..9e4bd55 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -42,7 +42,6 @@ template: !include template.yaml # calendar integration calendar: !include calendars.yaml -ics_calendar: !include ics_calendars.yaml # DB-recorder configuration recorder: !include recorder.yaml diff --git a/custom_components/ics_calendar/__init__.py b/custom_components/ics_calendar/__init__.py index b34768a..b7b6866 100644 --- a/custom_components/ics_calendar/__init__.py +++ b/custom_components/ics_calendar/__init__.py @@ -4,6 +4,7 @@ import logging import homeassistant.helpers.config_validation as cv import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_EXCLUDE, CONF_INCLUDE, @@ -14,24 +15,35 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import discovery +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, +) from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, UPGRADE_URL +from .const import ( + CONF_ACCEPT_HEADER, + CONF_ADV_CONNECT_OPTS, + CONF_CALENDARS, + CONF_CONNECTION_TIMEOUT, + CONF_DAYS, + CONF_DOWNLOAD_INTERVAL, + CONF_INCLUDE_ALL_DAY, + CONF_OFFSET_HOURS, + CONF_PARSER, + CONF_REQUIRES_AUTH, + CONF_SET_TIMEOUT, + CONF_SUMMARY_DEFAULT, + CONF_SUMMARY_DEFAULT_DEFAULT, + CONF_USER_AGENT, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.CALENDAR] -CONF_DEVICE_ID = "device_id" -CONF_CALENDARS = "calendars" -CONF_DAYS = "days" -CONF_INCLUDE_ALL_DAY = "include_all_day" -CONF_PARSER = "parser" -CONF_DOWNLOAD_INTERVAL = "download_interval" -CONF_USER_AGENT = "user_agent" -CONF_OFFSET_HOURS = "offset_hours" -CONF_ACCEPT_HEADER = "accept_header" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -81,6 +93,13 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional( CONF_ACCEPT_HEADER, default="" ): cv.string, + vol.Optional( + CONF_CONNECTION_TIMEOUT, default=300 + ): cv.positive_float, + vol.Optional( + CONF_SUMMARY_DEFAULT, + default=CONF_SUMMARY_DEFAULT_DEFAULT, + ): cv.string, } ) ] @@ -92,22 +111,150 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +STORAGE_KEY = DOMAIN +STORAGE_VERSION_MAJOR = 1 +STORAGE_VERSION_MINOR = 0 -def setup(hass: HomeAssistant, config: ConfigType) -> bool: + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up calendars.""" _LOGGER.debug("Setting up ics_calendar component") hass.data.setdefault(DOMAIN, {}) if DOMAIN in config and config[DOMAIN]: - hass.helpers.discovery.load_platform( - PLATFORMS[0], DOMAIN, config[DOMAIN], config + _LOGGER.debug("discovery.load_platform called") + discovery.load_platform( + hass=hass, + component=PLATFORMS[0], + platform=DOMAIN, + discovered=config[DOMAIN], + hass_config=config, ) - else: - _LOGGER.error( - "No configuration found! If you upgraded from ics_calendar v3.2.0 " - "or older, you need to update your configuration! See " - "%s for more information.", - UPGRADE_URL, + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_configuration", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="YAML_Warning", + ) + _LOGGER.warning( + "YAML configuration of ics_calendar is deprecated and will be " + "removed in ics_calendar v5.0.0. Your configuration items have " + "been imported. Please remove them from your configuration.yaml " + "file." + ) + + config_entry = _async_find_matching_config_entry(hass) + if not config_entry: + if config[DOMAIN].get("calendars"): + for calendar in config[DOMAIN].get("calendars"): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=dict(calendar), + ) + ) + return True + + # update entry with any changes + if config[DOMAIN].get("calendars"): + for calendar in config[DOMAIN].get("calendars"): + hass.config_entries.async_update_entry( + config_entry, data=dict(calendar) + ) + + return True + + +@callback +def _async_find_matching_config_entry(hass): + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.source == SOURCE_IMPORT: + return entry + return None + + +async def async_migrate_entry(hass, entry: ConfigEntry): + """Migrate old config entry.""" + # Don't downgrade entries + if entry.version > STORAGE_VERSION_MAJOR: + return False + + if entry.version == STORAGE_VERSION_MAJOR: + new_data = {**entry.data} + + hass.config_entries.async_update_entry( + entry, + data=new_data, + minor_version=STORAGE_VERSION_MINOR, + version=STORAGE_VERSION_MAJOR, ) return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Implement async_setup_entry.""" + full_data: dict = add_missing_defaults(entry) + hass.config_entries.async_update_entry(entry=entry, data=full_data) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = full_data + await hass.config_entries.async_forward_entry_setups(entry, ["calendar"]) + return True + + +def add_missing_defaults( + entry: ConfigEntry, +) -> dict: + """Initialize missing data.""" + data = { + CONF_NAME: "", + CONF_URL: "", + CONF_ADV_CONNECT_OPTS: False, + CONF_SET_TIMEOUT: False, + CONF_REQUIRES_AUTH: False, + CONF_INCLUDE_ALL_DAY: False, + CONF_REQUIRES_AUTH: False, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_PARSER: "rie", + CONF_PREFIX: "", + CONF_DAYS: 1, + CONF_DOWNLOAD_INTERVAL: 15, + CONF_USER_AGENT: "", + CONF_EXCLUDE: "", + CONF_INCLUDE: "", + CONF_OFFSET_HOURS: 0, + CONF_ACCEPT_HEADER: "", + CONF_CONNECTION_TIMEOUT: 300.0, + CONF_SUMMARY_DEFAULT: CONF_SUMMARY_DEFAULT_DEFAULT, + } + data.update(entry.data) + + if CONF_USERNAME in entry.data or CONF_PASSWORD in entry.data: + data[CONF_REQUIRES_AUTH] = True + if ( + CONF_USER_AGENT in entry.data + or CONF_ACCEPT_HEADER in entry.data + or CONF_CONNECTION_TIMEOUT in entry.data + ): + data[CONF_ADV_CONNECT_OPTS] = True + if CONF_CONNECTION_TIMEOUT in entry.data: + data[CONF_SET_TIMEOUT] = True + + return data + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload entry.""" + unload_ok = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/custom_components/ics_calendar/calendar.py b/custom_components/ics_calendar/calendar.py index 8fa6b23..5d33ad7 100644 --- a/custom_components/ics_calendar/calendar.py +++ b/custom_components/ics_calendar/calendar.py @@ -1,7 +1,8 @@ """Support for ICS Calendar.""" + import logging from datetime import datetime, timedelta -from typing import Optional +from typing import Any, Optional # import homeassistant.helpers.config_validation as cv # import voluptuous as vol @@ -12,6 +13,7 @@ from homeassistant.components.calendar import ( extract_offset, is_offset_reached, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_EXCLUDE, CONF_INCLUDE, @@ -24,23 +26,28 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle from homeassistant.util.dt import now as hanow -from . import ( +from .calendardata import CalendarData +from .const import ( CONF_ACCEPT_HEADER, CONF_CALENDARS, + CONF_CONNECTION_TIMEOUT, CONF_DAYS, CONF_DOWNLOAD_INTERVAL, CONF_INCLUDE_ALL_DAY, CONF_OFFSET_HOURS, CONF_PARSER, + CONF_SET_TIMEOUT, + CONF_SUMMARY_DEFAULT, CONF_USER_AGENT, + DOMAIN, ) -from .calendardata import CalendarData from .filter import Filter -from .icalendarparser import ICalendarParser +from .getparser import GetParser +from .parserevent import ParserEvent _LOGGER = logging.getLogger(__name__) @@ -51,6 +58,34 @@ OFFSET = "!!" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the calendar in background.""" + hass.async_create_task( + _async_setup_entry_bg_task(hass, config_entry, async_add_entities) + ) + + +async def _async_setup_entry_bg_task( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the calendar.""" + data = hass.data[DOMAIN][config_entry.entry_id] + device_id = f"{data[CONF_NAME]}" + entity = ICSCalendarEntity( + hass, + generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass), + hass.data[DOMAIN][config_entry.entry_id], + config_entry.entry_id, + ) + async_add_entities([entity]) + + def setup_platform( hass: HomeAssistant, config: ConfigType, @@ -70,8 +105,13 @@ def setup_platform( """ _LOGGER.debug("Setting up ics calendars") if discovery_info is not None: - calendars: list = discovery_info.get(CONF_CALENDARS) + _LOGGER.debug( + "setup_platform: ignoring discovery_info, already imported!" + ) + # calendars: list = discovery_info.get(CONF_CALENDARS) + calendars = [] else: + _LOGGER.debug("setup_platform: discovery_info is None") calendars: list = config.get(CONF_CALENDARS) calendar_devices = [] @@ -91,10 +131,13 @@ def setup_platform( CONF_INCLUDE: calendar.get(CONF_INCLUDE), CONF_OFFSET_HOURS: calendar.get(CONF_OFFSET_HOURS), CONF_ACCEPT_HEADER: calendar.get(CONF_ACCEPT_HEADER), + CONF_CONNECTION_TIMEOUT: calendar.get(CONF_CONNECTION_TIMEOUT), } device_id = f"{device_data[CONF_NAME]}" entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - calendar_devices.append(ICSCalendarEntity(entity_id, device_data)) + calendar_devices.append( + ICSCalendarEntity(hass, entity_id, device_data) + ) add_entities(calendar_devices) @@ -102,7 +145,13 @@ def setup_platform( class ICSCalendarEntity(CalendarEntity): """A CalendarEntity for an ICS Calendar.""" - def __init__(self, entity_id: str, device_data): + def __init__( + self, + hass: HomeAssistant, + entity_id: str, + device_data, + unique_id: str = None, + ): """Construct ICSCalendarEntity. :param entity_id: Entity id for the calendar @@ -111,14 +160,16 @@ class ICSCalendarEntity(CalendarEntity): :type device_data: dict """ _LOGGER.debug( - "Initializing calendar: %s with URL: %s", + "Initializing calendar: %s with URL: %s, uniqueid: %s", device_data[CONF_NAME], device_data[CONF_URL], + unique_id, ) - self.data = ICSCalendarData(device_data) + self.data = ICSCalendarData(hass, device_data) self.entity_id = entity_id + self._attr_unique_id = f"ICSCalendar.{unique_id}" self._event = None - self._name = device_data[CONF_NAME] + self._attr_name = device_data[CONF_NAME] self._last_call = None @property @@ -131,12 +182,7 @@ class ICSCalendarEntity(CalendarEntity): return self._event @property - def name(self): - """Return the name of the calendar.""" - return self._name - - @property - def should_poll(self): + def should_poll(self) -> bool: """Indicate if the calendar should be polled. If the last call to update or get_api_events was not within the minimum @@ -168,25 +214,50 @@ class ICSCalendarEntity(CalendarEntity): _LOGGER.debug( "%s: async_get_events called; calling internal.", self.name ) - return await self.data.async_get_events(hass, start_date, end_date) + return await self.data.async_get_events(start_date, end_date) - def update(self): + async def async_update(self): """Get the current or next event.""" - self.data.update() - self._event = self.data.event + await self.data.async_update() + self._event: CalendarEvent | None = self.data.event self._attr_extra_state_attributes = { - "offset_reached": is_offset_reached( - self._event.start_datetime_local, self.data.offset + "offset_reached": ( + is_offset_reached( + self._event.start_datetime_local, self.data.offset + ) + if self._event + else False ) - if self._event - else False } + async def async_create_event(self, **kwargs: Any): + """Raise error, this is a read-only calendar.""" + raise NotImplementedError() + + async def async_delete_event( + self, + uid: str, + recurrence_id: str | None = None, + recurrence_range: str | None = None, + ) -> None: + """Raise error, this is a read-only calendar.""" + raise NotImplementedError() + + async def async_update_event( + self, + uid: str, + event: dict[str, Any], + recurrence_id: str | None = None, + recurrence_range: str | None = None, + ) -> None: + """Raise error, this is a read-only calendar.""" + raise NotImplementedError() + class ICSCalendarData: # pylint: disable=R0902 """Class to use the calendar ICS client object to get next event.""" - def __init__(self, device_data): + def __init__(self, hass: HomeAssistant, device_data): """Set up how we are going to connect to the URL. :param device_data Information about the calendar @@ -196,18 +267,25 @@ class ICSCalendarData: # pylint: disable=R0902 self._offset_hours = device_data[CONF_OFFSET_HOURS] self.include_all_day = device_data[CONF_INCLUDE_ALL_DAY] self._summary_prefix: str = device_data[CONF_PREFIX] - self.parser = ICalendarParser.get_instance(device_data[CONF_PARSER]) + self._summary_default: str = device_data[CONF_SUMMARY_DEFAULT] + self.parser = GetParser.get_parser(device_data[CONF_PARSER]) self.parser.set_filter( Filter(device_data[CONF_EXCLUDE], device_data[CONF_INCLUDE]) ) self.offset = None self.event = None + self._hass = hass self._calendar_data = CalendarData( + get_async_client(hass), _LOGGER, - self.name, - device_data[CONF_URL], - timedelta(minutes=device_data[CONF_DOWNLOAD_INTERVAL]), + { + "name": self.name, + "url": device_data[CONF_URL], + "min_update_time": timedelta( + minutes=device_data[CONF_DOWNLOAD_INTERVAL] + ), + }, ) self._calendar_data.set_headers( @@ -217,22 +295,23 @@ class ICSCalendarData: # pylint: disable=R0902 device_data[CONF_ACCEPT_HEADER], ) + if device_data.get(CONF_SET_TIMEOUT): + self._calendar_data.set_timeout( + device_data[CONF_CONNECTION_TIMEOUT] + ) + async def async_get_events( - self, hass: HomeAssistant, start_date: datetime, end_date: datetime + self, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame. - :param hass: Home Assistant object - :type hass: HomeAssistant :param start_date: The first starting date to consider :type start_date: datetime :param end_date: The last starting date to consider :type end_date: datetime """ - event_list = [] - if await hass.async_add_executor_job( - self._calendar_data.download_calendar - ): + event_list: list[ParserEvent] = [] + if await self._calendar_data.download_calendar(): _LOGGER.debug("%s: Setting calendar content", self.name) self.parser.set_content(self._calendar_data.get()) try: @@ -248,22 +327,27 @@ class ICSCalendarData: # pylint: disable=R0902 self.name, exc_info=True, ) - event_list = [] + event_list: list[ParserEvent] = [] for event in event_list: event.summary = self._summary_prefix + event.summary + if not event.summary: + event.summary = self._summary_default + # Since we skipped the validation code earlier, invoke it now, + # before passing the object outside this component + event.validate() return event_list - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + async def async_update(self): """Get the current or next event.""" _LOGGER.debug("%s: Update was called", self.name) - if self._calendar_data.download_calendar(): + parser_event: ParserEvent | None = None + if await self._calendar_data.download_calendar(): _LOGGER.debug("%s: Setting calendar content", self.name) self.parser.set_content(self._calendar_data.get()) try: - self.event = self.parser.get_current_event( + parser_event: ParserEvent | None = self.parser.get_current_event( include_all_day=self.include_all_day, now=hanow(), days=self._days, @@ -273,18 +357,24 @@ class ICSCalendarData: # pylint: disable=R0902 _LOGGER.error( "update: %s: Failed to parse ICS!", self.name, exc_info=True ) - if self.event is not None: + if parser_event is not None: _LOGGER.debug( "%s: got event: %s; start: %s; end: %s; all_day: %s", self.name, - self.event.summary, - self.event.start, - self.event.end, - self.event.all_day, + parser_event.summary, + parser_event.start, + parser_event.end, + parser_event.all_day, ) - (summary, offset) = extract_offset(self.event.summary, OFFSET) - self.event.summary = self._summary_prefix + summary + (summary, offset) = extract_offset(parser_event.summary, OFFSET) + parser_event.summary = self._summary_prefix + summary + if not parser_event.summary: + parser_event.summary = self._summary_default self.offset = offset + # Invoke validation here, since it was skipped when creating the + # ParserEvent + parser_event.validate() + self.event: CalendarEvent = parser_event return True _LOGGER.debug("%s: No event found!", self.name) diff --git a/custom_components/ics_calendar/calendardata.py b/custom_components/ics_calendar/calendardata.py index 2d870ea..0364814 100644 --- a/custom_components/ics_calendar/calendardata.py +++ b/custom_components/ics_calendar/calendardata.py @@ -1,23 +1,25 @@ """Provide CalendarData class.""" -import zlib -from datetime import timedelta -from gzip import BadGzipFile, GzipFile -from logging import Logger -from threading import Lock -from urllib.error import ContentTooShortError, HTTPError, URLError -from urllib.request import ( - HTTPBasicAuthHandler, - HTTPDigestAuthHandler, - HTTPPasswordMgrWithDefaultRealm, - build_opener, - install_opener, - urlopen, -) +import re +from logging import Logger +from math import floor + +import httpx +import httpx_auth from homeassistant.util.dt import now as hanow +# from urllib.error import ContentTooShortError, HTTPError, URLError -class CalendarData: + +class DigestWithMultiAuth(httpx.DigestAuth, httpx_auth.SupportMultiAuth): + """Describes a DigestAuth authentication.""" + + def __init__(self, username: str, password: str): + """Construct Digest authentication that supports Multi Auth.""" + httpx.DigestAuth.__init__(self, username, password) + + +class CalendarData: # pylint: disable=R0902 """CalendarData class. The CalendarData class is used to download and cache calendar data from a @@ -25,32 +27,33 @@ class CalendarData: instance. """ - opener_lock = Lock() - def __init__( - self, logger: Logger, name: str, url: str, min_update_time: timedelta + self, + async_client: httpx.AsyncClient, + logger: Logger, + conf: dict, ): """Construct CalendarData object. + :param async_client: An httpx.AsyncClient object for requests + :type httpx.AsyncClient :param logger: The logger for reporting problems :type logger: Logger - :param name: The name of the calendar (used for reporting problems) - :type name: str - :param url: The URL of the calendar - :type url: str - :param min_update_time: The minimum time between downloading data from - the URL when requested - :type min_update_time: timedelta + :param conf: Configuration options + :type conf: dict """ + self._auth = None self._calendar_data = None + self._headers = [] self._last_download = None - self._min_update_time = min_update_time - self._opener = None + self._min_update_time = conf["min_update_time"] self.logger = logger - self.name = name - self.url = url + self.name = conf["name"] + self.url = conf["url"] + self.connection_timeout = None + self._httpx = async_client - def download_calendar(self) -> bool: + async def download_calendar(self) -> bool: """Download the calendar data. This only downloads data if self.min_update_time has passed since the @@ -59,20 +62,25 @@ class CalendarData: returns: True if data was downloaded, otherwise False. rtype: bool """ - now = hanow() + self.logger.debug("%s: download_calendar start", self.name) if ( self._calendar_data is None or self._last_download is None - or (now - self._last_download) > self._min_update_time + or (hanow() - self._last_download) > self._min_update_time ): - self._last_download = now self._calendar_data = None + next_url: str = self._make_url() self.logger.debug( - "%s: Downloading calendar data from: %s", self.name, self.url + "%s: Downloading calendar data from: %s", + self.name, + next_url, ) - self._download_data() + await self._download_data(next_url) + self._last_download = hanow() + self.logger.debug("%s: download_calendar done", self.name) return self._calendar_data is not None + self.logger.debug("%s: download_calendar skipped download", self.name) return False def get(self) -> str: @@ -92,10 +100,8 @@ class CalendarData: ): """Set a user agent, accept header, and/or user name and password. - The user name and password will be set into an HTTPBasicAuthHandler an - an HTTPDigestAuthHandler. Both are attached to a new urlopener, so - that HTTP Basic Auth and HTTP Digest Auth will be supported when - opening the URL. + The user name and password will be set into an auth object that + supports both Basic Auth and Digest Auth for httpx. If the user_agent parameter is not "", a User-agent header will be added to the urlopener. @@ -110,81 +116,63 @@ class CalendarData: :type accept_header: str """ if user_name != "" and password != "": - passman = HTTPPasswordMgrWithDefaultRealm() - passman.add_password(None, self.url, user_name, password) - basic_auth_handler = HTTPBasicAuthHandler(passman) - digest_auth_handler = HTTPDigestAuthHandler(passman) - self._opener = build_opener( - digest_auth_handler, basic_auth_handler - ) + self._auth = httpx_auth.Basic( + user_name, password + ) + DigestWithMultiAuth(user_name, password) - additional_headers = [] if user_agent != "": - additional_headers.append(("User-agent", user_agent)) + self._headers.append(("User-agent", user_agent)) if accept_header != "": - additional_headers.append(("Accept", accept_header)) - if len(additional_headers) > 0: - if self._opener is None: - self._opener = build_opener() - self._opener.addheaders = additional_headers + self._headers.append(("Accept", accept_header)) - def _decode_data(self, conn): - if ( - "Content-Encoding" in conn.headers - and conn.headers["Content-Encoding"] == "gzip" - ): - reader = GzipFile(fileobj=conn) - else: - reader = conn - try: - return self._decode_stream(reader.read()).replace("\0", "") - except zlib.error: - self.logger.error( - "%s: Failed to uncompress gzip data from url(%s): zlib", - self.name, - self.url, - ) - except BadGzipFile as gzip_error: - self.logger.error( - "%s: Failed to uncompress gzip data from url(%s): %s", - self.name, - self.url, - gzip_error.strerror, - ) - return None + def set_timeout(self, connection_timeout: float): + """Set the connection timeout. - def _decode_stream(self, strm): - for encoding in "utf-8-sig", "utf-8", "utf-16": - try: - return strm.decode(encoding) - except UnicodeDecodeError: - continue - return None + :param connection_timeout: The timeout value in seconds. + :type connection_timeout: float + """ + self.connection_timeout = connection_timeout - def _download_data(self): + def _decode_data(self, data): + return data.replace("\0", "") + + async def _download_data(self, url): # noqa: C901 """Download the calendar data.""" + self.logger.debug("%s: _download_data start", self.name) try: - with CalendarData.opener_lock: - if self._opener is not None: - install_opener(self._opener) - with urlopen(self._make_url()) as conn: - self._calendar_data = self._decode_data(conn) - except HTTPError as http_error: + response = await self._httpx.get( + url, + auth=self._auth, + headers=self._headers, + follow_redirects=True, + timeout=self.connection_timeout, + ) + if response.status_code >= 400: + raise httpx.HTTPStatusError( + "status error", request=None, response=response + ) + self._calendar_data = self._decode_data(response.text) + self.logger.debug("%s: _download_data done", self.name) + except httpx.HTTPStatusError as http_status_error: self.logger.error( "%s: Failed to open url(%s): %s", self.name, self.url, - http_error.reason, + http_status_error.response.status_code, ) - except ContentTooShortError as content_too_short_error: + except httpx.TimeoutException: self.logger.error( - "%s: Could not download calendar data: %s", - self.name, - content_too_short_error.reason, + "%s: Timeout opening url: %s", self.name, self.url ) - except URLError as url_error: + except httpx.DecodingError: self.logger.error( - "%s: Failed to open url: %s", self.name, url_error.reason + "%s: Error decoding data from url: %s", self.name, self.url + ) + except httpx.InvalidURL: + self.logger.error("%s: Invalid URL: %s", self.name, self.url) + except httpx.HTTPError: + self.logger.error( + "%s: Error decoding data from url: %s", self.name, self.url ) except: # pylint: disable=W0702 self.logger.error( @@ -192,7 +180,45 @@ class CalendarData: ) def _make_url(self): + """Replace templates in url and encode.""" now = hanow() - return self.url.replace("{year}", f"{now.year:04}").replace( - "{month}", f"{now.month:02}" + year: int = now.year + month: int = now.month + url = self.url + (month, year, url) = self._get_month_year(url, month, year) + return url.replace("{year}", f"{year:04}").replace( + "{month}", f"{month:02}" ) + + def _get_year_as_months(self, url: str, month: int) -> int: + year_match = re.search("\\{year([-+])([0-9]+)\\}", url) + if year_match: + if year_match.group(1) == "-": + month = month - (int(year_match.group(2)) * 12) + else: + month = month + (int(year_match.group(2)) * 12) + url = url.replace(year_match.group(0), "{year}") + return (month, url) + + def _get_month_year(self, url: str, month: int, year: int) -> int: + (month, url) = self._get_year_as_months(url, month) + print(f"month: {month}\n") + month_match = re.search("\\{month([-+])([0-9]+)\\}", url) + if month_match: + if month_match.group(1) == "-": + month = month - int(month_match.group(2)) + else: + month = month + int(month_match.group(2)) + if month < 1: + year -= floor(abs(month) / 12) + 1 + month = month % 12 + if month == 0: + month = 12 + elif month > 12: + year += abs(floor(month / 12)) + month = month % 12 + if month == 0: + month = 12 + year -= 1 + url = url.replace(month_match.group(0), "{month}") + return (month, year, url) diff --git a/custom_components/ics_calendar/config_flow.py b/custom_components/ics_calendar/config_flow.py new file mode 100644 index 0000000..af16ac3 --- /dev/null +++ b/custom_components/ics_calendar/config_flow.py @@ -0,0 +1,330 @@ +"""Config Flow for ICS Calendar.""" + +import logging +import re +from typing import Any, Dict, Optional, Self +from urllib.parse import quote + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_EXCLUDE, + CONF_INCLUDE, + CONF_NAME, + CONF_PASSWORD, + CONF_PREFIX, + CONF_URL, + CONF_USERNAME, +) +from homeassistant.helpers.selector import selector + +from . import ( + CONF_ACCEPT_HEADER, + CONF_ADV_CONNECT_OPTS, + CONF_CONNECTION_TIMEOUT, + CONF_DAYS, + CONF_DOWNLOAD_INTERVAL, + CONF_INCLUDE_ALL_DAY, + CONF_OFFSET_HOURS, + CONF_PARSER, + CONF_REQUIRES_AUTH, + CONF_SET_TIMEOUT, + CONF_SUMMARY_DEFAULT, + CONF_USER_AGENT, +) +from .const import CONF_SUMMARY_DEFAULT_DEFAULT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CALENDAR_NAME_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_DAYS, default=1): cv.positive_int, + vol.Optional(CONF_INCLUDE_ALL_DAY, default=False): cv.boolean, + } +) + +CALENDAR_OPTS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_EXCLUDE, default=""): cv.string, + vol.Optional(CONF_INCLUDE, default=""): cv.string, + vol.Optional(CONF_PREFIX, default=""): cv.string, + vol.Optional(CONF_DOWNLOAD_INTERVAL, default=15): cv.positive_int, + vol.Optional(CONF_OFFSET_HOURS, default=0): int, + vol.Optional(CONF_PARSER, default="rie"): selector( + {"select": {"options": ["rie", "ics"], "mode": "dropdown"}} + ), + vol.Optional( + CONF_SUMMARY_DEFAULT, default=CONF_SUMMARY_DEFAULT_DEFAULT + ): cv.string, + } +) + +CONNECT_OPTS_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_REQUIRES_AUTH, default=False): cv.boolean, + vol.Optional(CONF_ADV_CONNECT_OPTS, default=False): cv.boolean, + } +) + +AUTH_OPTS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_USERNAME, default=""): cv.string, + vol.Optional(CONF_PASSWORD, default=""): cv.string, + } +) + +ADVANCED_CONNECT_OPTS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ACCEPT_HEADER, default=""): cv.string, + vol.Optional(CONF_USER_AGENT, default=""): cv.string, + vol.Optional(CONF_SET_TIMEOUT, default=False): cv.boolean, + } +) + +TIMEOUT_OPTS_SCHEMA = vol.Schema( + {vol.Optional(CONF_CONNECTION_TIMEOUT, default=None): cv.positive_float} +) + + +def is_array_string(arr_str: str) -> bool: + """Return true if arr_str starts with [ and ends with ].""" + return arr_str.startswith("[") and arr_str.endswith("]") + + +def format_url(url: str) -> str: + """Format a URL using quote() and ensure any templates are not quoted.""" + is_quoted = bool(re.search("%[0-9A-Fa-f][0-9A-Fa-f]", url)) + if not is_quoted: + year_match = re.search("\\{(year([-+][0-9]+)?)\\}", url) + month_match = re.search("\\{(month([-+][0-9]+)?)\\}", url) + has_template: bool = year_match or month_match + url = quote(url, safe=":/?&=") + if has_template: + year_template = year_match.group(1) + month_template = month_match.group(1) + year_template1 = year_template.replace("+", "%2[Bb]") + month_template1 = month_template.replace("+", "%2[Bb]") + url = re.sub( + f"%7[Bb]{year_template1}%7[Dd]", + f"{{{year_template}}}", + url, + ) + url = re.sub( + f"%7[Bb]{month_template1}%7[Dd]", + f"{{{month_template}}}", + url, + ) + if url.startswith("webcal://"): + url = re.sub("^webcal://", "https://", url) + + return url + + +class ICSCalendarConfigFlow(ConfigFlow, domain=DOMAIN): + """Config Flow for ICS Calendar.""" + + VERSION = 1 + MINOR_VERSION = 0 + + data: Optional[Dict[str, Any]] + + def __init__(self): + """Construct ICSCalendarConfigFlow.""" + self.data = {} + + def is_matching(self, _other_flow: Self) -> bool: + """Match discovery method. + + This method doesn't do anything, because this integration has no + discoverable components. + """ + return False + + async def async_step_reauth(self, user_input=None): + """Re-authenticateon auth error.""" + # self.reauth_entry = self.hass.config_entries.async_get_entry( + # self.context["entry_id"] + # ) + return await self.async_step_reauth_confirm(user_input) + + async def async_step_reauth_confirm( + self, user_input=None + ) -> ConfigFlowResult: + """Dialog to inform user that reauthentication is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", data_schema=vol.Schema({}) + ) + return await self.async_step_user() + + # Don't allow reconfigure for now! + # async def async_step_reconfigure( + # self, user_input: dict[str, Any] | None = None + # ) -> ConfigFlowResult: + # """Reconfigure entry.""" + # return await self.async_step_user(user_input) + + async def async_step_user( + self, user_input: Optional[Dict[str, Any]] = None + ) -> ConfigFlowResult: + """Start of Config Flow.""" + errors = {} + if user_input is not None: + user_input[CONF_NAME] = user_input[CONF_NAME].strip() + if not user_input[CONF_NAME]: + errors[CONF_NAME] = "empty_name" + else: + self._async_abort_entries_match( + {CONF_NAME: user_input[CONF_NAME]} + ) + + if not errors: + self.data = user_input + return await self.async_step_calendar_opts() + + return self.async_show_form( + step_id="user", + data_schema=CALENDAR_NAME_SCHEMA, + errors=errors, + last_step=False, + ) + + async def async_step_calendar_opts( # noqa: R701,C901 + self, user_input: Optional[Dict[str, Any]] = None + ): + """Calendar Options step for ConfigFlow.""" + errors = {} + if user_input is not None: + user_input[CONF_EXCLUDE] = user_input[CONF_EXCLUDE].strip() + user_input[CONF_INCLUDE] = user_input[CONF_INCLUDE].strip() + if ( + user_input[CONF_EXCLUDE] + and user_input[CONF_EXCLUDE] == user_input[CONF_INCLUDE] + ): + errors[CONF_EXCLUDE] = "exclude_include_cannot_be_the_same" + else: + if user_input[CONF_EXCLUDE] and not is_array_string( + user_input[CONF_EXCLUDE] + ): + errors[CONF_EXCLUDE] = "exclude_must_be_array" + if user_input[CONF_INCLUDE] and not is_array_string( + user_input[CONF_INCLUDE] + ): + errors[CONF_INCLUDE] = "include_must_be_array" + + if user_input[CONF_DOWNLOAD_INTERVAL] < 15: + _LOGGER.error("download_interval_too_small error") + errors[CONF_DOWNLOAD_INTERVAL] = "download_interval_too_small" + + if not user_input[CONF_SUMMARY_DEFAULT]: + user_input[CONF_SUMMARY_DEFAULT] = CONF_SUMMARY_DEFAULT_DEFAULT + + if not errors: + self.data.update(user_input) + return await self.async_step_connect_opts() + + return self.async_show_form( + step_id="calendar_opts", + data_schema=CALENDAR_OPTS_SCHEMA, + errors=errors, + last_step=False, + ) + + async def async_step_connect_opts( + self, user_input: Optional[Dict[str, Any]] = None + ): + """Connect Options step for ConfigFlow.""" + errors = {} + if user_input is not None: + user_input[CONF_URL] = user_input[CONF_URL].strip() + if not user_input[CONF_URL]: + errors[CONF_URL] = "empty_url" + + if not errors: + user_input[CONF_URL] = format_url(user_input[CONF_URL]) + + self.data.update(user_input) + if user_input.get(CONF_REQUIRES_AUTH, False): + return await self.async_step_auth_opts() + if user_input.get(CONF_ADV_CONNECT_OPTS, False): + return await self.async_step_adv_connect_opts() + return self.async_create_entry( + title=self.data[CONF_NAME], + data=self.data, + ) + + return self.async_show_form( + step_id="connect_opts", + data_schema=CONNECT_OPTS_SCHEMA, + errors=errors, + ) + + async def async_step_auth_opts( + self, user_input: Optional[Dict[str, Any]] = None + ): + """Auth Options step for ConfigFlow.""" + if user_input is not None: + self.data.update(user_input) + if self.data.get(CONF_ADV_CONNECT_OPTS, False): + return await self.async_step_adv_connect_opts() + return self.async_create_entry( + title=self.data[CONF_NAME], + data=self.data, + ) + + return self.async_show_form( + step_id="auth_opts", data_schema=AUTH_OPTS_SCHEMA + ) + + async def async_step_adv_connect_opts( + self, user_input: Optional[Dict[str, Any]] = None + ): + """Advanced Connection Options step for ConfigFlow.""" + errors = {} + if user_input is not None: + + if not errors: + self.data.update(user_input) + if user_input.get(CONF_SET_TIMEOUT, False): + return await self.async_step_timeout_opts() + return self.async_create_entry( + title=self.data[CONF_NAME], + data=self.data, + ) + + return self.async_show_form( + step_id="adv_connect_opts", + data_schema=ADVANCED_CONNECT_OPTS_SCHEMA, + errors=errors, + ) + + async def async_step_timeout_opts( + self, user_input: Optional[Dict[str, Any]] = None + ): + """Timeout Options step for ConfigFlow.""" + errors = {} + if user_input is not None: + + if not errors: + self.data.update(user_input) + return self.async_create_entry( + title=self.data[CONF_NAME], + data=self.data, + ) + + return self.async_show_form( + step_id="timeout_opts", + data_schema=TIMEOUT_OPTS_SCHEMA, + errors=errors, + last_step=True, + ) + + async def async_step_import(self, import_data): + """Import config from configuration.yaml.""" + return self.async_create_entry( + title=import_data[CONF_NAME], + data=import_data, + ) diff --git a/custom_components/ics_calendar/const.py b/custom_components/ics_calendar/const.py index 524b424..74d1416 100644 --- a/custom_components/ics_calendar/const.py +++ b/custom_components/ics_calendar/const.py @@ -1,7 +1,24 @@ """Constants for ics_calendar platform.""" -VERSION = "4.1.0" + +VERSION = "5.1.3" DOMAIN = "ics_calendar" -UPGRADE_URL = ( - "https://github.com/franc6/ics_calendar/blob/releases/" - "UpgradeTo4.0AndLater.md" -) + +CONF_DEVICE_ID = "device_id" +CONF_CALENDARS = "calendars" +CONF_DAYS = "days" +CONF_INCLUDE_ALL_DAY = "include_all_day" +CONF_PARSER = "parser" +CONF_DOWNLOAD_INTERVAL = "download_interval" +CONF_USER_AGENT = "user_agent" +CONF_OFFSET_HOURS = "offset_hours" +CONF_ACCEPT_HEADER = "accept_header" +CONF_CONNECTION_TIMEOUT = "connection_timeout" +CONF_SET_TIMEOUT = "set_connection_timeout" +CONF_REQUIRES_AUTH = "requires_auth" +CONF_ADV_CONNECT_OPTS = "advanced_connection_options" +CONF_SUMMARY_DEFAULT = "summary_default" +# It'd be really nifty if this could be a translatable string, but it seems +# that's not supported, unless I want to roll my own interpretation of the +# translate/*.json files. :( +# See also https://github.com/home-assistant/core/issues/125075 +CONF_SUMMARY_DEFAULT_DEFAULT = "No title" diff --git a/custom_components/ics_calendar/filter.py b/custom_components/ics_calendar/filter.py index 4787ded..0778b5a 100644 --- a/custom_components/ics_calendar/filter.py +++ b/custom_components/ics_calendar/filter.py @@ -1,9 +1,10 @@ """Provide Filter class.""" + import re from ast import literal_eval from typing import List, Optional, Pattern -from homeassistant.components.calendar import CalendarEvent +from .parserevent import ParserEvent class Filter: @@ -113,11 +114,11 @@ class Filter: add_event = self._is_included(summary, description) return add_event - def filter_event(self, event: CalendarEvent) -> bool: + def filter_event(self, event: ParserEvent) -> bool: """Check if the event should be included or not. :param event: The event to examine - :type event: CalendarEvent + :type event: ParserEvent :return: true if the event should be included, otherwise false :rtype: bool """ diff --git a/custom_components/ics_calendar/getparser.py b/custom_components/ics_calendar/getparser.py new file mode 100644 index 0000000..594c2e2 --- /dev/null +++ b/custom_components/ics_calendar/getparser.py @@ -0,0 +1,27 @@ +"""Provide GetParser class.""" + +from .icalendarparser import ICalendarParser +from .parsers.parser_ics import ParserICS +from .parsers.parser_rie import ParserRIE + + +class GetParser: # pylint: disable=R0903 + """Provide get_parser to return an instance of ICalendarParser. + + The class provides a static method , get_instace, to get a parser instance. + The non static methods allow this class to act as an "interface" for the + parser classes. + """ + + @staticmethod + def get_parser(parser: str, *args) -> ICalendarParser | None: + """Get an instance of the requested parser.""" + # parser_cls = ICalendarParser.get_class(parser) + # if parser_cls is not None: + # return parser_cls(*args) + if parser == "rie": + return ParserRIE(*args) + if parser == "ics": + return ParserICS(*args) + + return None diff --git a/custom_components/ics_calendar/icalendarparser.py b/custom_components/ics_calendar/icalendarparser.py index dc9e1d0..fb4c08c 100644 --- a/custom_components/ics_calendar/icalendarparser.py +++ b/custom_components/ics_calendar/icalendarparser.py @@ -1,39 +1,14 @@ """Provide ICalendarParser class.""" -import importlib + from datetime import datetime from typing import Optional -from homeassistant.components.calendar import CalendarEvent - from .filter import Filter +from .parserevent import ParserEvent class ICalendarParser: - """Provide interface for various parser classes. - - The class provides a static method , get_instace, to get a parser instance. - The non static methods allow this class to act as an "interface" for the - parser classes. - """ - - @staticmethod - def get_class(parser: str): - """Get the class of the requested parser.""" - parser_module_name = ".parsers.parser_" + parser - parser = "Parser" + parser.upper() - try: - module = importlib.import_module(parser_module_name, __package__) - return getattr(module, parser) - except ImportError: - return None - - @staticmethod - def get_instance(parser: str, *args): - """Get an instance of the requested parser.""" - parser_cls = ICalendarParser.get_class(parser) - if parser_cls is not None: - return parser_cls(*args) - return None + """Provide interface for various parser classes.""" def set_content(self, content: str): """Parse content into a calendar object. @@ -57,7 +32,7 @@ class ICalendarParser: end: datetime, include_all_day: bool, offset_hours: int = 0, - ) -> list[CalendarEvent]: + ) -> list[ParserEvent]: """Get a list of events. Gets the events from start to end, including or excluding all day @@ -71,7 +46,7 @@ class ICalendarParser: :param offset_hours the number of hours to offset the event :type offset_hours int :returns a list of events, or an empty list - :rtype list[CalendarEvent] + :rtype list[ParserEvent] """ def get_current_event( @@ -80,7 +55,7 @@ class ICalendarParser: now: datetime, days: int, offset_hours: int = 0, - ) -> Optional[CalendarEvent]: + ) -> Optional[ParserEvent]: """Get the current or next event. Gets the current event, or the next upcoming event with in the @@ -93,5 +68,5 @@ class ICalendarParser: :type days int :param offset_hours the number of hours to offset the event :type offset_hours int - :returns a CalendarEvent or None + :returns a ParserEvent or None """ diff --git a/custom_components/ics_calendar/manifest.json b/custom_components/ics_calendar/manifest.json index c52135c..b2636cd 100644 --- a/custom_components/ics_calendar/manifest.json +++ b/custom_components/ics_calendar/manifest.json @@ -1,13 +1,13 @@ { - "domain": "ics_calendar", "name": "ics Calendar", "codeowners": ["@franc6"], + "config_flow": true, "dependencies": [], "documentation": "https://github.com/franc6/ics_calendar", "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/franc6/ics_calendar/issues", - "requirements": ["ics>=0.7.2", "recurring_ical_events>=2.0.2", "icalendar>=5.0.4"], - "version": "4.1.0" + "requirements": ["icalendar~=6.1","python-dateutil>=2.9.0.post0","pytz>=2024.1","recurring_ical_events~=3.5,>=3.5.2","ics==0.7.2","arrow","httpx_auth>=0.22.0,<=0.23.1"], + "version": "5.1.3" } diff --git a/custom_components/ics_calendar/parserevent.py b/custom_components/ics_calendar/parserevent.py new file mode 100644 index 0000000..67181e0 --- /dev/null +++ b/custom_components/ics_calendar/parserevent.py @@ -0,0 +1,20 @@ +"""Provide ParserEvent class.""" + +import dataclasses + +from homeassistant.components.calendar import CalendarEvent + + +@dataclasses.dataclass +class ParserEvent(CalendarEvent): + """Class to represent CalendarEvent without validation.""" + + def validate(self) -> None: + """Invoke __post_init__ from CalendarEvent.""" + return super().__post_init__() + + def __post_init__(self) -> None: + """Don't do validation steps for this class.""" + # This is necessary to prevent problems when creating events that don't + # have a summary. We'll add a summary after the event is created, not + # before, to reduce code repitition. diff --git a/custom_components/ics_calendar/parsers/parser_ics.py b/custom_components/ics_calendar/parsers/parser_ics.py index fc1b5e9..5e10cd1 100644 --- a/custom_components/ics_calendar/parsers/parser_ics.py +++ b/custom_components/ics_calendar/parsers/parser_ics.py @@ -1,14 +1,15 @@ """Support for ics parser.""" + import re from datetime import date, datetime, timedelta from typing import Optional, Union from arrow import Arrow, get as arrowget -from homeassistant.components.calendar import CalendarEvent from ics import Calendar from ..filter import Filter from ..icalendarparser import ICalendarParser +from ..parserevent import ParserEvent from ..utility import compare_event_dates @@ -41,7 +42,7 @@ class ParserICS(ICalendarParser): def get_event_list( self, start, end, include_all_day: bool, offset_hours: int = 0 - ) -> list[CalendarEvent]: + ) -> list[ParserEvent]: """Get a list of events. Gets the events from start to end, including or excluding all day @@ -55,9 +56,9 @@ class ParserICS(ICalendarParser): :param offset_hours the number of hours to offset the event :type offset_hours int :returns a list of events, or an empty list - :rtype list[CalendarEvent] + :rtype list[ParserEvent] """ - event_list: list[CalendarEvent] = [] + event_list: list[ParserEvent] = [] if self._calendar is not None: # ics 0.8 takes datetime not Arrow objects @@ -75,7 +76,7 @@ class ParserICS(ICalendarParser): # summary = event.summary # elif hasattr(event, "name"): summary = event.name - calendar_event: CalendarEvent = CalendarEvent( + calendar_event: ParserEvent = ParserEvent( summary=summary, start=ParserICS.get_date( event.begin, event.all_day, offset_hours @@ -97,7 +98,7 @@ class ParserICS(ICalendarParser): now: datetime, days: int, offset_hours: int = 0, - ) -> Optional[CalendarEvent]: + ) -> Optional[ParserEvent]: """Get the current or next event. Gets the current event, or the next upcoming event with in the @@ -110,7 +111,7 @@ class ParserICS(ICalendarParser): :type int :param offset_hours the number of hours to offset the event :type int - :returns a CalendarEvent or None + :returns a ParserEvent or None """ if self._calendar is None: return None @@ -144,7 +145,7 @@ class ParserICS(ICalendarParser): # summary = temp_event.summary # elif hasattr(event, "name"): summary = temp_event.name - return CalendarEvent( + return ParserEvent( summary=summary, start=ParserICS.get_date( temp_event.begin, temp_event.all_day, offset_hours diff --git a/custom_components/ics_calendar/parsers/parser_rie.py b/custom_components/ics_calendar/parsers/parser_rie.py index 5d71f7a..8902142 100644 --- a/custom_components/ics_calendar/parsers/parser_rie.py +++ b/custom_components/ics_calendar/parsers/parser_rie.py @@ -1,13 +1,14 @@ """Support for recurring_ical_events parser.""" + from datetime import date, datetime, timedelta from typing import Optional, Union import recurring_ical_events as rie -from homeassistant.components.calendar import CalendarEvent from icalendar import Calendar from ..filter import Filter from ..icalendarparser import ICalendarParser +from ..parserevent import ParserEvent from ..utility import compare_event_dates @@ -45,7 +46,7 @@ class ParserRIE(ICalendarParser): end: datetime, include_all_day: bool, offset_hours: int = 0, - ) -> list[CalendarEvent]: + ) -> list[ParserEvent]: """Get a list of events. Gets the events from start to end, including or excluding all day @@ -59,12 +60,12 @@ class ParserRIE(ICalendarParser): :param offset_hours the number of hours to offset the event :type offset_hours int :returns a list of events, or an empty list - :rtype list[CalendarEvent] + :rtype list[ParserEvent] """ - event_list: list[CalendarEvent] = [] + event_list: list[ParserEvent] = [] if self._calendar is not None: - for event in rie.of(self._calendar).between( + for event in rie.of(self._calendar, skip_bad_series=True).between( start - timedelta(hours=offset_hours), end - timedelta(hours=offset_hours), ): @@ -73,7 +74,7 @@ class ParserRIE(ICalendarParser): if all_day and not include_all_day: continue - calendar_event: CalendarEvent = CalendarEvent( + calendar_event: ParserEvent = ParserEvent( summary=event.get("SUMMARY"), start=start, end=end, @@ -91,7 +92,7 @@ class ParserRIE(ICalendarParser): now: datetime, days: int, offset_hours: int = 0, - ) -> Optional[CalendarEvent]: + ) -> Optional[ParserEvent]: """Get the current or next event. Gets the current event, or the next upcoming event with in the @@ -104,17 +105,17 @@ class ParserRIE(ICalendarParser): :type int :param offset_hours the number of hours to offset the event :type offset_hours int - :returns a CalendarEvent or None + :returns a ParserEvent or None """ if self._calendar is None: return None - temp_event: CalendarEvent = None + temp_event = None temp_start: date | datetime = None temp_end: date | datetime = None temp_all_day: bool = None end: datetime = now + timedelta(days=days) - for event in rie.of(self._calendar).between( + for event in rie.of(self._calendar, skip_bad_series=True).between( now - timedelta(hours=offset_hours), end - timedelta(hours=offset_hours), ): @@ -139,7 +140,7 @@ class ParserRIE(ICalendarParser): if temp_event is None: return None - return CalendarEvent( + return ParserEvent( summary=temp_event.get("SUMMARY"), start=temp_start, end=temp_end, diff --git a/custom_components/ics_calendar/strings.json b/custom_components/ics_calendar/strings.json new file mode 100644 index 0000000..dee0db3 --- /dev/null +++ b/custom_components/ics_calendar/strings.json @@ -0,0 +1,77 @@ +{ + "issues": { + "YAML_Warning": { + "title": "YAML configuration is deprecated for ICS Calendar", + "description": "YAML configuration of ics_calendar is deprecated and will be removed in ics_calendar v5.0.0. Your configuration items have been imported. Please remove them from your configuration.yaml file." + } + }, + "title": "ICS Calendar", + "config": { + "step": { + "user": { + "data": { + "name": "Name", + "days": "Days", + "include_all_day": "Include all day events?" + }, + "title": "Add Calendar" + }, + "calendar_opts": { + "data": { + "exclude": "Exclude filter", + "include": "Include filter", + "prefix": "String to prefix all event summaries", + "download_interval": "Download interval (minutes)", + "offset_hours": "Number of hours to offset event times", + "parser": "Parser (rie or ics)", + "summary_default": "Summary if event doesn't have one" + }, + "title": "Calendar Options" + }, + "connect_opts": { + "data": { + "url": "URL of ICS file", + "requires_auth": "Requires authentication?", + "advanced_connection_options": "Set advanced connection options?" + }, + "title": "Connection Options" + }, + "auth_opts": { + "data": { + "username": "Username", + "password": "Password" + }, + "description": "Please note this component supports only HTTP Basic Auth and HTTP Digest Auth. More advanced authentication, like OAuth is not supported at this time.", + "title": "Authentication" + }, + "adv_connect_opts": { + "data": { + "accept_header": "Custom Accept header for broken servers", + "user_agent": "Custom User-agent header", + "set_connection_timeout": "Change connection timeout?" + }, + "title": "Advanced Connection Options" + }, + "timeout_opts": { + "data": { + "connection_timeout": "Connection timeout in seconds" + }, + "title": "Connection Timeout Options" + }, + "reauth_confirm": { + "description": "Authorization failed for calendar. Please re-configured the calendar URL and/or authentication settings.", + "title": "Authorization Failure for ICS Calendar" + } + }, + "error": { + "empty_name": "The calendar name must not be empty.", + "empty_url": "The url must not be empty.", + "download_interval_too_small": "The download interval must be at least 15.", + "exclude_include_cannot_be_the_same": "The exclude and include strings must not be the same", + "exclude_must_be_array": "The exclude option must be an array of strings or regular expressions. See https://github.com/franc6/ics_calendar/blob/releases/README.md#filters for more information.", + "include_must_be_array": "The include option must be an array of strings or regular expressions. See https://github.com/franc6/ics_calendar/blob/releases/README.md#filters for more information." + }, + "abort": { + } + } +} \ No newline at end of file diff --git a/custom_components/ics_calendar/translations/de.json b/custom_components/ics_calendar/translations/de.json new file mode 100644 index 0000000..130a0e0 --- /dev/null +++ b/custom_components/ics_calendar/translations/de.json @@ -0,0 +1,76 @@ +{ + "issues": { + "YAML_Warning": { + "title": "YAML-Konfiguration für ICS-Kalender ist veraltet", + "description": "Die YAML-Konfiguration von ics_calendar ist veraltet und wird in ics_calendar v5.0.0 entfernt. Deine Konfigurationselemente wurden importiert. Bitte entferne sie aus deiner configuration.yaml-Datei." + } + }, + "title": "ICS-Kalender", + "config": { + "step": { + "user": { + "data": { + "name": "Name", + "days": "Tage", + "include_all_day": "Ganztägige Ereignisse einbeziehen?" + }, + "title": "Kalender hinzufügen" + }, + "calendar_opts": { + "data": { + "exclude": "auszuschließende Ereignisse", + "include": "einzuschließende Ereignisse", + "prefix": "String, um allen Zusammenfassungen ein Präfix hinzuzufügen", + "download_interval": "Download-Intervall (Minuten)", + "offset_hours": "Anzahl der Stunden, um Ereigniszeiten zu versetzen", + "parser": "Parser (rie oder ics)" + }, + "title": "Kalender-Optionen" + }, + "connect_opts": { + "data": { + "url": "URL der ICS-Datei", + "requires_auth": "Erfordert Authentifizierung?", + "advanced_connection_options": "Erweiterte Verbindungsoptionen festlegen?" + }, + "title": "Verbindungsoptionen" + }, + "auth_opts": { + "data": { + "username": "Benutzername", + "password": "Passwort" + }, + "description": "Bitte beachte, dass nur HTTP Basic Auth und HTTP Digest Auth unterstützt wird. Authentifizierungsmethoden wie OAuth werden derzeit nicht unterstützt.", + "title": "Authentifizierung" + }, + "adv_connect_opts": { + "data": { + "accept_header": "Eigener Accept-Header für fehlerhafte Server", + "user_agent": "Eigener User-Agent-Header", + "set_connection_timeout": "Verbindungstimeout ändern?" + }, + "title": "Erweiterte Verbindungsoptionen" + }, + "timeout_opts": { + "data": { + "connection_timeout": "Verbindungstimeout in Sekunden" + }, + "title": "Verbindungstimeout-Optionen" + }, + "reauth_confirm": { + "description": "Die Autorisierung für den Kalender ist fehlgeschlagen. Bitte konfiguriere die Kalender-URL und/oder die Authentifizierungseinstellungen neu.", + "title": "Autorisierungsfehler für ICS-Kalender" + } + }, + "error": { + "empty_name": "Der Kalendername darf nicht leer sein.", + "empty_url": "Die URL darf nicht leer sein.", + "download_interval_too_small": "Das Download-Intervall muss mindestens 15 betragen.", + "exclude_include_cannot_be_the_same": "Die Ausschluss- und Einschluss-Strings dürfen nicht identisch sein.", + "exclude_must_be_array": "Die \"auszuschließenden Ereignisse\" müssen ein Array von Zeichenfolgen oder regulären Ausdrücken sein. Weitere Informationen finden Sie unter https://github.com/franc6/ics_calendar/blob/releases/README.md#filters.", + "include_must_be_array": "Die \"einzuschließenden Ereignisse\" müssen ein Array von Zeichenfolgen oder regulären Ausdrücken sein. Weitere Informationen finden Sie unter https://github.com/franc6/ics_calendar/blob/releases/README.md#filters." + }, + "abort": { + } + } +} diff --git a/custom_components/ics_calendar/translations/en.json b/custom_components/ics_calendar/translations/en.json new file mode 100644 index 0000000..dee0db3 --- /dev/null +++ b/custom_components/ics_calendar/translations/en.json @@ -0,0 +1,77 @@ +{ + "issues": { + "YAML_Warning": { + "title": "YAML configuration is deprecated for ICS Calendar", + "description": "YAML configuration of ics_calendar is deprecated and will be removed in ics_calendar v5.0.0. Your configuration items have been imported. Please remove them from your configuration.yaml file." + } + }, + "title": "ICS Calendar", + "config": { + "step": { + "user": { + "data": { + "name": "Name", + "days": "Days", + "include_all_day": "Include all day events?" + }, + "title": "Add Calendar" + }, + "calendar_opts": { + "data": { + "exclude": "Exclude filter", + "include": "Include filter", + "prefix": "String to prefix all event summaries", + "download_interval": "Download interval (minutes)", + "offset_hours": "Number of hours to offset event times", + "parser": "Parser (rie or ics)", + "summary_default": "Summary if event doesn't have one" + }, + "title": "Calendar Options" + }, + "connect_opts": { + "data": { + "url": "URL of ICS file", + "requires_auth": "Requires authentication?", + "advanced_connection_options": "Set advanced connection options?" + }, + "title": "Connection Options" + }, + "auth_opts": { + "data": { + "username": "Username", + "password": "Password" + }, + "description": "Please note this component supports only HTTP Basic Auth and HTTP Digest Auth. More advanced authentication, like OAuth is not supported at this time.", + "title": "Authentication" + }, + "adv_connect_opts": { + "data": { + "accept_header": "Custom Accept header for broken servers", + "user_agent": "Custom User-agent header", + "set_connection_timeout": "Change connection timeout?" + }, + "title": "Advanced Connection Options" + }, + "timeout_opts": { + "data": { + "connection_timeout": "Connection timeout in seconds" + }, + "title": "Connection Timeout Options" + }, + "reauth_confirm": { + "description": "Authorization failed for calendar. Please re-configured the calendar URL and/or authentication settings.", + "title": "Authorization Failure for ICS Calendar" + } + }, + "error": { + "empty_name": "The calendar name must not be empty.", + "empty_url": "The url must not be empty.", + "download_interval_too_small": "The download interval must be at least 15.", + "exclude_include_cannot_be_the_same": "The exclude and include strings must not be the same", + "exclude_must_be_array": "The exclude option must be an array of strings or regular expressions. See https://github.com/franc6/ics_calendar/blob/releases/README.md#filters for more information.", + "include_must_be_array": "The include option must be an array of strings or regular expressions. See https://github.com/franc6/ics_calendar/blob/releases/README.md#filters for more information." + }, + "abort": { + } + } +} \ No newline at end of file diff --git a/custom_components/ics_calendar/translations/es.json b/custom_components/ics_calendar/translations/es.json new file mode 100644 index 0000000..ca74ff7 --- /dev/null +++ b/custom_components/ics_calendar/translations/es.json @@ -0,0 +1,77 @@ +{ + "issues": { + "YAML_Warning": { + "title": "La configuración YAML está obsoleta para ICS Calendar", + "description": "La configuración YAML de ics_calendar está obsoleta y se eliminará en ics_calendar v5.0.0. Sus elementos de configuración se han importado. Elimínelos de su archivo configuration.yaml." + } + }, + "title": "ICS Calendar", + "config": { + "step": { + "user": { + "data": { + "name": "Nombre", + "days": "Días", + "include_all_day": "¿Incluir eventos de todo el día?" + }, + "title": "Agregar calendario" + }, + "calendar_opts": { + "data": { + "exclude": "Excluir filtro", + "include": "Incluir filtro", + "prefix": "Cadena que precederá a todos los resúmenes de eventos", + "download_interval": "Intervalo de descarga (minutos)", + "offset_hours": "Número de horas para compensar los tiempos del evento", + "parser": "Parser (rie or ics)", + "summary_default": "Resumen si el evento no tiene uno" + }, + "title": "Opciones de calendario" + }, + "connect_opts": { + "data": { + "url": "URL del archivo ICS", + "requires_auth": "¿Requiere autentificación?", + "advanced_connection_options": "¿Establecer opciones de conexión avanzadas?" + }, + "title": "Opciones de conexión" + }, + "auth_opts": { + "data": { + "username": "Nombre de usuario", + "password": "Contraseña" + }, + "description": "Tenga en cuenta que este componente solo admite la autenticación básica HTTP y la autenticación HTTP Digest. Actualmente, no se admiten autenticaciones más avanzadas, como OAuth.", + "title": "Autentificación" + }, + "adv_connect_opts": { + "data": { + "accept_header": "Encabezado Accept personalizado para servidores rotos", + "user_agent": "Encabezado de agente de usuario personalizado", + "set_connection_timeout": "¿Cambiar el tiempo de espera de la conexión?" + }, + "title": "Opciones avanzadas de conexión" + }, + "timeout_opts": { + "data": { + "connection_timeout": "Tiempo de espera de la conexión en segundos" + }, + "title": "Opciones de tiempo de espera de la conexión" + }, + "reauth_confirm": { + "description": "Error de autorización para el calendario. Vuelva a configurar la URL del calendario y/o los ajustes de autenticación.", + "title": "Fallo de autorización para ICS Calendar" + } + }, + "error": { + "empty_name": "El nombre del calendario no debe estar vacío.", + "empty_url": "La url no debe estar vacía.", + "download_interval_too_small": "El intervalo de descarga debe ser de al menos 15.", + "exclude_include_cannot_be_the_same": "Las cadenas de exclusión e inclusión no deben ser las mismas", + "exclude_must_be_array": "La opción de exclusión debe ser una matriz de cadenas o expresiones regulares. Consulte https://github.com/franc6/ics_calendar/blob/releases/README.md#filters para obtener más información.", + "include_must_be_array": "La opción de inclusión debe ser un array de cadenas o expresiones regulares. Consulte https://github.com/franc6/ics_calendar/blob/releases/README.md#filters para obtener más información." + }, + "abort": { + } + } +} diff --git a/custom_components/ics_calendar/translations/fr.json b/custom_components/ics_calendar/translations/fr.json new file mode 100644 index 0000000..4212e4b --- /dev/null +++ b/custom_components/ics_calendar/translations/fr.json @@ -0,0 +1,76 @@ +{ + "issues": { + "YAML_Warning": { + "title": "La configuration YAML pour ICS Calendar est obsolète", + "description": "La configuration YAML d'ICS Calendar est obsolète et sera supprimée dans la version 5.0.0 d'ics_calendar. Les éléments de votre configuration ont été importés. Veuillez les supprimer de votre fichier configuration.yaml." + } + }, + "title": "ICS Calendar", + "config": { + "step": { + "user": { + "data": { + "name": "Nom", + "days": "Jours", + "include_all_day": "Inclure les événements à la journée ?" + }, + "title": "Ajouter un calendrier" + }, + "calendar_opts": { + "data": { + "exclude": "Exclure les événements contenant", + "include": "Inclure les événements contenant", + "prefix": "Préfixer tous les résumés d'événements avec", + "download_interval": "Intervalle de téléchargement (minutes)", + "offset_hours": "Décalage à appliquer aux horaires des événements (heures)", + "parser": "Parseur (rie ou ics)" + }, + "title": "Options du calendrier" + }, + "connect_opts": { + "data": { + "url": "URL du fichier ICS", + "requires_auth": "Authentification requise ?", + "advanced_connection_options": "Définir les options avancées de la connexion ?" + }, + "title": "Options de connexion" + }, + "auth_opts": { + "data": { + "username": "Utilisateur", + "password": "Mot de passe" + }, + "description": "Veuillez noter que cette intégration ne supporte que les modes d'authentification HTTP Basic et HTTP Digest. Les méthodes d'authentification plus avancées, telles que OAuth, ne sont pas supportées actuellement.", + "title": "Authentification" + }, + "adv_connect_opts": { + "data": { + "accept_header": "Entête 'Accept' personnalisée pour les serveurs injoignables", + "user_agent": "Entête 'User-agent' personnalisée", + "set_connection_timeout": "Modifier le délai maximum autorisé pour la connexion ?" + }, + "title": "Options avancées de connexion" + }, + "timeout_opts": { + "data": { + "connection_timeout": "Délai maximum autorisé pour la connexion (secondes)" + }, + "title": "Options de délai de connexion" + }, + "reauth_confirm": { + "description": "L'autorisation a échoué pour le calendrier. Veuillez vérifier l'URL du calendrier et/ou les paramètres d'authentification.", + "title": "Échec d'autorisation pour ICS Calendar" + } + }, + "error": { + "empty_name": "Le nom du calendrier doit être renseigné.", + "empty_url": "L'URL du calendrier doit être renseignée.", + "download_interval_too_small": "L'intervalle de téléchargement ne peut pas être inférieur à 15 minutes.", + "exclude_include_cannot_be_the_same": "Les valeurs d'exclusion et d'inclusion ne peuvent pas être identiques.", + "exclude_must_be_array": "The exclude option must be an array of strings or regular expressions. See https://github.com/franc6/ics_calendar/blob/releases/README.md#filters for more information.", + "include_must_be_array": "The include option must be an array of strings or regular expressions. See https://github.com/franc6/ics_calendar/blob/releases/README.md#filters for more information." + }, + "abort": { + } + } +} diff --git a/custom_components/ics_calendar/translations/pt-br.json b/custom_components/ics_calendar/translations/pt-br.json new file mode 100644 index 0000000..40dc547 --- /dev/null +++ b/custom_components/ics_calendar/translations/pt-br.json @@ -0,0 +1,77 @@ +{ + "issues": { + "YAML_Warning": { + "title": "A configuração YAML está obsoleta para o ICS Calendar", + "description": "A configuração YAML do ics_calendar está obsoleta e será removida na versão 5.0.0 do ics_calendar. Seus itens de configuração foram importados. Por favor, remova-os do seu arquivo configuration.yaml." + } + }, + "title": "ICS Calendar", + "config": { + "step": { + "user": { + "data": { + "name": "Nome", + "days": "Dias", + "include_all_day": "Incluir eventos de dia inteiro?" + }, + "title": "Adicionar Calendário" + }, + "calendar_opts": { + "data": { + "exclude": "Filtro de exclusão", + "include": "Filtro de inclusão", + "prefix": "Texto para prefixar todos os resumos de eventos", + "download_interval": "Intervalo de download (minutos)", + "offset_hours": "Número de horas para ajustar os horários dos eventos", + "parser": "Parser (rie ou ics)", + "summary_default": "Resumo padrão se o evento não tiver um" + }, + "title": "Opções do Calendário" + }, + "connect_opts": { + "data": { + "url": "URL do arquivo ICS", + "requires_auth": "Requer autenticação?", + "advanced_connection_options": "Definir opções de conexão avançadas?" + }, + "title": "Opções de Conexão" + }, + "auth_opts": { + "data": { + "username": "Usuário", + "password": "Senha" + }, + "description": "Este componente oferece suporte apenas para HTTP Basic Auth e HTTP Digest Auth. Métodos de autenticação mais avançados, como OAuth, ainda não são suportados.", + "title": "Autenticação" + }, + "adv_connect_opts": { + "data": { + "accept_header": "Cabeçalho Accept personalizado para servidores com problemas", + "user_agent": "Cabeçalho User-agent personalizado", + "set_connection_timeout": "Alterar tempo limite de conexão?" + }, + "title": "Opções Avançadas de Conexão" + }, + "timeout_opts": { + "data": { + "connection_timeout": "Tempo limite de conexão em segundos" + }, + "title": "Opções de Tempo Limite de Conexão" + }, + "reauth_confirm": { + "description": "A autorização falhou para o calendário. Por favor, reconfigure a URL do calendário e/ou as configurações de autenticação.", + "title": "Falha de Autorização para o ICS Calendar" + } + }, + "error": { + "empty_name": "O nome do calendário não pode estar vazio.", + "empty_url": "A URL não pode estar vazia.", + "download_interval_too_small": "O intervalo de download deve ser de pelo menos 15.", + "exclude_include_cannot_be_the_same": "As strings de exclusão e inclusão não podem ser as mesmas.", + "exclude_must_be_array": "A opção de exclusão deve ser um array de strings ou expressões regulares. Veja https://github.com/franc6/ics_calendar/blob/releases/README.md#filters para mais informações.", + "include_must_be_array": "A opção de inclusão deve ser um array de strings ou expressões regulares. Veja https://github.com/franc6/ics_calendar/blob/releases/README.md#filters para mais informações." + }, + "abort": { + } + } +} diff --git a/custom_components/ics_calendar/utility.py b/custom_components/ics_calendar/utility.py index 821e454..ed3bde7 100644 --- a/custom_components/ics_calendar/utility.py +++ b/custom_components/ics_calendar/utility.py @@ -1,4 +1,5 @@ """Utility methods.""" + from datetime import date, datetime @@ -9,7 +10,7 @@ def make_datetime(val): return val -def compare_event_dates( # pylint: disable=R0913 +def compare_event_dates( # pylint: disable=R0913,R0917 now, end2, start2, all_day2, end, start, all_day ) -> bool: """Determine if end2 and start2 are newer than end and start.""" diff --git a/custom_components/ics_calendar_old.tar.bz2 b/custom_components/ics_calendar_old.tar.bz2 new file mode 100644 index 0000000000000000000000000000000000000000..66bf0a55a47c6e8e042be76d27ce4eef0db67cbd GIT binary patch literal 27600 zcmV(tKvzg%FJ=IwRRqM2A=z* z+8o-D&!y%ZJKW!9{N~uYWH9V&BNy+*)LEgRveZLxkF=T*iRC4l75hX&$YcJ^#ltJ7 zi(D=Qx;xiyQ?MG^gk+Y*UySqhZ7qPKkWX+Gg638)wwAsY?Q5EKpE*?J{fKbyc9{0C z(qv8m`d)@<`_JqU;c`k)T1xBB06!7w!dDb0$Q$HE%RGeqY7~Me<~^AS0G7w7%L(F z`4#rdy@5^8FBGrH?8&i1&r>7w#sU6o=sJynLo=R?r|S+9?3_ShbMn9=8wbXV1>p^9 z_$Xz&B1BgdD2U_4FVQYsRypR*;e-&@0^bj4X4%akU$3KRSkV!SaG$$zDFbY|HL<3D zW}9U@1(bOe`hN^kx3`-UzJ)ox#OgC@u;jkfgU;J91V5N6#4Wd~du=X_{;cef`Voks zvI|RNq^|bB2UgYodC^&HLFQZv-1$d%-Q%WPf6id3)n)xHPD=Pw=9T3fPc4a{Q`xq0 zMO_m;4=TEo?*XLrxid0ETbW^>tLn`XbzaL{bUR!{DYU!5b?G`*!fQpR?~`xF8xy+5 zJ`&TcWMhsMRF!CYrjl)M$x+u$RW(m!)(<+PnNiGU-lOD_M28p5G4_(pA@<&Ef3F8re=P2p@fQ34mMLuygz4^K7hG!7&U5d%p@7 z^s>TlE|v>VB*zRSa!QOr?mu|ky6bui?LDy7?rzcKb zjbiGR+}m*?r7Ns-OkS67cRu+6tY&sm7I7eYRdV~6N~q3j*jn@E(#lljB~Y7e;~`7t zamy;VX6%?`KI*8lM|qneTqQ`Cn!o=PtTgu zX1AuB3U^I&GtckD8G@JpwsEF2)g^2Z^YX~8@|U-aQ5EfIi*?UdI;GJGGZHg=_FfE* z`G7|%u@se(&I7A?jRS^>e|=elbN}M3w|#JIr`Hm0rBB+=dJ=2)cyr{>dL+ge@;C}? zUePNaEO8=^+j}i#!k~um!8vomFN;RfC-VRfKQ8xKB&dt= z%5j?^s~_zxkx7m_XoxLsM#JsZpx0R!Yl2wz%{t0K>(?^_`zOE0&{cgPu4}XrpXRwfe*3785yv7Ck_;JiCp}`hoogB?8(| zU)-{rlt3x61k?COcdQ!F?`9wJ{>42m#PXpVU?Y(ozhAHgC1L@>PhuM%(EP%P3;sG9 zS{5Rz^@w8>=x!unpFb6_i)(uTPXG@ZK^~x`ZyI=MyJ7+h`F%>8$RoojtJxu~3^PIh z0*y`wasqq9!rga}K~n_th@>|b2wFY8tBm4&@h8s8Y)Gj<^XcvW8X_9s{K<*Blcsg6 zpchN3A_5$(P*Iwu02p+Fmi!YK87^>2K`4&>rN}`uItWI2XE-#f+KXv3C}4Pen*V$3 zXkdY)ABUNJ4FQ6c2Yc7(s)C)@K&bz99_gwW5yrCvj+2}&xFmLEXC8QMvIv_n2(Wjr zJu!Y2DK5>cH$sZ!i7??EAP=ZUb?~6B+uC=fdtQaCcgxKkIe?W%A`E0FaZ zc%?W;^Cd_2{#B>1Pc6-}gPX5I5d=vRF4zMgMEgA!k|t)#WvBsL$QO%yn*1qyx0s4D zzPKq`F=C;1#3vxJr2=xoS4krHHf-~#E~^g4N6;}T0nWx&sHkTm6_Cv#&=+}RX%0DH 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 DE@P4E literal 0 HcmV?d00001