"""CalDAV todo platform.""" from __future__ import annotations import asyncio from datetime import date, datetime, timedelta from functools import partial import logging from typing import Any, cast import caldav from caldav.lib.error import DAVError, NotFoundError import requests from homeassistant.components.todo import ( TodoItem, TodoItemStatus, TodoListEntity, TodoListEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from .api import async_get_calendars, get_attr_value from .const import DOMAIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=15) SUPPORTED_COMPONENT = "VTODO" TODO_STATUS_MAP = { "NEEDS-ACTION": TodoItemStatus.NEEDS_ACTION, "IN-PROCESS": TodoItemStatus.NEEDS_ACTION, "COMPLETED": TodoItemStatus.COMPLETED, "CANCELLED": TodoItemStatus.COMPLETED, } TODO_STATUS_MAP_INV: dict[TodoItemStatus, str] = { TodoItemStatus.NEEDS_ACTION: "NEEDS-ACTION", TodoItemStatus.COMPLETED: "COMPLETED", } async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the CalDav todo platform for a config entry.""" client: caldav.DAVClient = hass.data[DOMAIN][entry.entry_id] calendars = await async_get_calendars(hass, client, SUPPORTED_COMPONENT) async_add_entities( ( WebDavTodoListEntity( calendar, entry.entry_id, ) for calendar in calendars ), True, ) def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: """Convert a caldav Todo into a TodoItem.""" if ( not hasattr(resource.instance, "vtodo") or not (todo := resource.instance.vtodo) or (uid := get_attr_value(todo, "uid")) is None or (summary := get_attr_value(todo, "summary")) is None ): return None due: date | datetime | None = None if due_value := get_attr_value(todo, "due"): if isinstance(due_value, datetime): due = dt_util.as_local(due_value) elif isinstance(due_value, date): due = due_value return TodoItem( uid=uid, summary=summary, status=TODO_STATUS_MAP.get( get_attr_value(todo, "status") or "", TodoItemStatus.NEEDS_ACTION, ), due=due, description=get_attr_value(todo, "description"), ) class WebDavTodoListEntity(TodoListEntity): """CalDAV To-do list entity.""" _attr_has_entity_name = True _attr_supported_features = ( TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None: """Initialize WebDavTodoListEntity.""" self._calendar = calendar self._attr_name = (calendar.name or "Unknown").capitalize() self._attr_unique_id = f"{config_entry_id}-{calendar.id}" async def async_update(self) -> None: """Update To-do list entity state.""" results = await self.hass.async_add_executor_job( partial( self._calendar.search, todo=True, include_completed=True, ) ) self._attr_todo_items = [ todo_item for resource in results if (todo_item := _todo_item(resource)) is not None ] async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" item_data: dict[str, Any] = {} if summary := item.summary: item_data["summary"] = summary if status := item.status: item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") if due := item.due: item_data["due"] = due if description := item.description: item_data["description"] = description try: await self.hass.async_add_executor_job( partial(self._calendar.save_todo, **item_data), ) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err async def async_update_todo_item(self, item: TodoItem) -> None: """Update a To-do item.""" uid: str = cast(str, item.uid) try: todo = await self.hass.async_add_executor_job( self._calendar.todo_by_uid, uid ) except NotFoundError as err: raise HomeAssistantError(f"Could not find To-do item {uid}") from err except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err vtodo = todo.icalendar_component # type: ignore[attr-defined] vtodo["SUMMARY"] = item.summary or "" if status := item.status: vtodo["STATUS"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") if due := item.due: todo.set_due(due) # type: ignore[attr-defined] else: vtodo.pop("DUE", None) if description := item.description: vtodo["DESCRIPTION"] = description else: vtodo.pop("DESCRIPTION", None) try: await self.hass.async_add_executor_job( partial( todo.save, no_create=True, obj_type="todo", ), ) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err async def async_delete_todo_items(self, uids: list[str]) -> None: """Delete To-do items.""" tasks = ( self.hass.async_add_executor_job(self._calendar.todo_by_uid, uid) for uid in uids ) try: items = await asyncio.gather(*tasks) except NotFoundError as err: raise HomeAssistantError("Could not find To-do item") from err except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err # Run serially as some CalDAV servers do not support concurrent modifications for item in items: try: await self.hass.async_add_executor_job(item.delete) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV delete error: {err}") from err