diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index e94206317d7..292f8237776 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -1,5 +1,6 @@ """A Local To-do todo platform.""" +import datetime import logging from ical.calendar import Calendar @@ -24,7 +25,8 @@ from .store import LocalTodoListStore _LOGGER = logging.getLogger(__name__) -PRODID = "-//homeassistant.io//local_todo 1.0//EN" +PRODID = "-//homeassistant.io//local_todo 2.0//EN" +PRODID_REQUIRES_MIGRATION = "-//homeassistant.io//local_todo 1.0//EN" ICS_TODO_STATUS_MAP = { TodoStatus.IN_PROCESS: TodoItemStatus.NEEDS_ACTION, @@ -38,6 +40,25 @@ ICS_TODO_STATUS_MAP_INV = { } +def _migrate_calendar(calendar: Calendar) -> bool: + """Upgrade due dates to rfc5545 format. + + In rfc5545 due dates are exclusive, however we previously set the due date + as inclusive based on what the user set in the UI. A task is considered + overdue at midnight at the start of a date so we need to shift the due date + to the next day for old calendar versions. + """ + if calendar.prodid is None or calendar.prodid != PRODID_REQUIRES_MIGRATION: + return False + migrated = False + for todo in calendar.todos: + if todo.due is None or isinstance(todo.due, datetime.datetime): + continue + todo.due += datetime.timedelta(days=1) + migrated = True + return migrated + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -48,12 +69,16 @@ async def async_setup_entry( store = hass.data[DOMAIN][config_entry.entry_id] ics = await store.async_load() calendar = IcsCalendarStream.calendar_from_ics(ics) + migrated = _migrate_calendar(calendar) calendar.prodid = PRODID name = config_entry.data[CONF_TODO_LIST_NAME] entity = LocalTodoListEntity(store, calendar, name, unique_id=config_entry.entry_id) async_add_entities([entity], True) + if migrated: + await entity.async_save() + def _convert_item(item: TodoItem) -> Todo: """Convert a HomeAssistant TodoItem to an ical Todo.""" @@ -65,6 +90,8 @@ def _convert_item(item: TodoItem) -> Todo: if item.status: todo.status = ICS_TODO_STATUS_MAP_INV[item.status] todo.due = item.due + if todo.due and not isinstance(todo.due, datetime.datetime): + todo.due += datetime.timedelta(days=1) todo.description = item.description return todo @@ -99,31 +126,36 @@ class LocalTodoListEntity(TodoListEntity): async def async_update(self) -> None: """Update entity state based on the local To-do items.""" - self._attr_todo_items = [ - TodoItem( - uid=item.uid, - summary=item.summary or "", - status=ICS_TODO_STATUS_MAP.get( - item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION - ), - due=item.due, - description=item.description, + todo_items = [] + for item in self._calendar.todos: + if (due := item.due) and not isinstance(due, datetime.datetime): + due -= datetime.timedelta(days=1) + todo_items.append( + TodoItem( + uid=item.uid, + summary=item.summary or "", + status=ICS_TODO_STATUS_MAP.get( + item.status or TodoStatus.NEEDS_ACTION, + TodoItemStatus.NEEDS_ACTION, + ), + due=due, + description=item.description, + ) ) - for item in self._calendar.todos - ] + self._attr_todo_items = todo_items async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" todo = _convert_item(item) TodoStore(self._calendar).add(todo) - await self._async_save() + await self.async_save() await self.async_update_ha_state(force_refresh=True) async def async_update_todo_item(self, item: TodoItem) -> None: """Update an item to the To-do list.""" todo = _convert_item(item) TodoStore(self._calendar).edit(todo.uid, todo) - await self._async_save() + await self.async_save() await self.async_update_ha_state(force_refresh=True) async def async_delete_todo_items(self, uids: list[str]) -> None: @@ -131,7 +163,7 @@ class LocalTodoListEntity(TodoListEntity): store = TodoStore(self._calendar) for uid in uids: store.delete(uid) - await self._async_save() + await self.async_save() await self.async_update_ha_state(force_refresh=True) async def async_move_todo_item( @@ -156,10 +188,10 @@ class LocalTodoListEntity(TodoListEntity): if dst_idx > src_idx: dst_idx -= 1 todos.insert(dst_idx, src_item) - await self._async_save() + await self.async_save() await self.async_update_ha_state(force_refresh=True) - async def _async_save(self) -> None: + async def async_save(self) -> None: """Persist the todo list to disk.""" content = IcsCalendarStream.calendar_to_ics(self._calendar) await self._store.async_store(content) diff --git a/tests/components/local_todo/snapshots/test_todo.ambr b/tests/components/local_todo/snapshots/test_todo.ambr new file mode 100644 index 00000000000..db4403f301c --- /dev/null +++ b/tests/components/local_todo/snapshots/test_todo.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_parse_existing_ics[completed] + list([ + dict({ + 'status': 'completed', + 'summary': 'Complete Task', + 'uid': '077cb7f2-6c89-11ee-b2a9-0242ac110002', + }), + ]) +# --- +# name: test_parse_existing_ics[due] + list([ + dict({ + 'due': '2023-10-23', + 'status': 'needs_action', + 'summary': 'Task', + 'uid': '077cb7f2-6c89-11ee-b2a9-0242ac110002', + }), + ]) +# --- +# name: test_parse_existing_ics[empty] + list([ + ]) +# --- +# name: test_parse_existing_ics[migrate_legacy_due] + list([ + dict({ + 'due': '2023-10-23', + 'status': 'needs_action', + 'summary': 'Task', + 'uid': '077cb7f2-6c89-11ee-b2a9-0242ac110002', + }), + ]) +# --- +# name: test_parse_existing_ics[needs_action] + list([ + dict({ + 'status': 'needs_action', + 'summary': 'Incomplete Task', + 'uid': '077cb7f2-6c89-11ee-b2a9-0242ac110002', + }), + ]) +# --- +# name: test_parse_existing_ics[not_exists] + list([ + ]) +# --- diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 22d8abade50..231f56b0afb 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -5,6 +5,7 @@ import textwrap from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.core import HomeAssistant @@ -628,13 +629,64 @@ async def test_move_item_previous_unknown( ), "1", ), + ( + textwrap.dedent( + """\ + BEGIN:VCALENDAR + PRODID:-//homeassistant.io//local_todo 1.0//EN + VERSION:2.0 + BEGIN:VTODO + DTSTAMP:20231024T014011 + UID:077cb7f2-6c89-11ee-b2a9-0242ac110002 + CREATED:20231017T010348 + LAST-MODIFIED:20231024T014011 + SEQUENCE:1 + STATUS:NEEDS-ACTION + SUMMARY:Task + DUE:20231023 + END:VTODO + END:VCALENDAR + """ + ), + "1", + ), + ( + textwrap.dedent( + """\ + BEGIN:VCALENDAR + PRODID:-//homeassistant.io//local_todo 2.0//EN + VERSION:2.0 + BEGIN:VTODO + DTSTAMP:20231024T014011 + UID:077cb7f2-6c89-11ee-b2a9-0242ac110002 + CREATED:20231017T010348 + LAST-MODIFIED:20231024T014011 + SEQUENCE:1 + STATUS:NEEDS-ACTION + SUMMARY:Task + DUE:20231024 + END:VTODO + END:VCALENDAR + """ + ), + "1", + ), ], - ids=("empty", "not_exists", "completed", "needs_action"), + ids=( + "empty", + "not_exists", + "completed", + "needs_action", + "migrate_legacy_due", + "due", + ), ) async def test_parse_existing_ics( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + snapshot: SnapshotAssertion, expected_state: str, ) -> None: """Test parsing ics content.""" @@ -643,6 +695,9 @@ async def test_parse_existing_ics( assert state assert state.state == expected_state + items = await ws_get_items() + assert items == snapshot + async def test_susbcribe( hass: HomeAssistant,