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 0000000..66bf0a5 Binary files /dev/null and b/custom_components/ics_calendar_old.tar.bz2 differ