Updated ics_calendar to restore compatibility with HA

This commit is contained in:
Marcus Scholz 2025-05-08 10:02:22 +02:00
parent fef90d5a78
commit ca599eab7a
21 changed files with 1329 additions and 234 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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,
)

View File

@ -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"

View File

@ -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
"""

View File

@ -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

View File

@ -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
"""

View File

@ -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"
}

View File

@ -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.

View File

@ -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

View File

@ -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,

View File

@ -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": {
}
}
}

View File

@ -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": {
}
}
}

View File

@ -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": {
}
}
}

View File

@ -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": {
}
}
}

View File

@ -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": {
}
}
}

View File

@ -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": {
}
}
}

View File

@ -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."""

Binary file not shown.