Fix local todo list persistence for due dates (#110830)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>pull/110844/head
parent
9ac199a8f2
commit
babb436512
|
@ -1,5 +1,6 @@
|
||||||
"""A Local To-do todo platform."""
|
"""A Local To-do todo platform."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from ical.calendar import Calendar
|
from ical.calendar import Calendar
|
||||||
|
@ -24,7 +25,8 @@ from .store import LocalTodoListStore
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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 = {
|
ICS_TODO_STATUS_MAP = {
|
||||||
TodoStatus.IN_PROCESS: TodoItemStatus.NEEDS_ACTION,
|
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(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
|
@ -48,12 +69,16 @@ async def async_setup_entry(
|
||||||
store = hass.data[DOMAIN][config_entry.entry_id]
|
store = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
ics = await store.async_load()
|
ics = await store.async_load()
|
||||||
calendar = IcsCalendarStream.calendar_from_ics(ics)
|
calendar = IcsCalendarStream.calendar_from_ics(ics)
|
||||||
|
migrated = _migrate_calendar(calendar)
|
||||||
calendar.prodid = PRODID
|
calendar.prodid = PRODID
|
||||||
|
|
||||||
name = config_entry.data[CONF_TODO_LIST_NAME]
|
name = config_entry.data[CONF_TODO_LIST_NAME]
|
||||||
entity = LocalTodoListEntity(store, calendar, name, unique_id=config_entry.entry_id)
|
entity = LocalTodoListEntity(store, calendar, name, unique_id=config_entry.entry_id)
|
||||||
async_add_entities([entity], True)
|
async_add_entities([entity], True)
|
||||||
|
|
||||||
|
if migrated:
|
||||||
|
await entity.async_save()
|
||||||
|
|
||||||
|
|
||||||
def _convert_item(item: TodoItem) -> Todo:
|
def _convert_item(item: TodoItem) -> Todo:
|
||||||
"""Convert a HomeAssistant TodoItem to an ical Todo."""
|
"""Convert a HomeAssistant TodoItem to an ical Todo."""
|
||||||
|
@ -65,6 +90,8 @@ def _convert_item(item: TodoItem) -> Todo:
|
||||||
if item.status:
|
if item.status:
|
||||||
todo.status = ICS_TODO_STATUS_MAP_INV[item.status]
|
todo.status = ICS_TODO_STATUS_MAP_INV[item.status]
|
||||||
todo.due = item.due
|
todo.due = item.due
|
||||||
|
if todo.due and not isinstance(todo.due, datetime.datetime):
|
||||||
|
todo.due += datetime.timedelta(days=1)
|
||||||
todo.description = item.description
|
todo.description = item.description
|
||||||
return todo
|
return todo
|
||||||
|
|
||||||
|
@ -99,31 +126,36 @@ class LocalTodoListEntity(TodoListEntity):
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
async def async_update(self) -> None:
|
||||||
"""Update entity state based on the local To-do items."""
|
"""Update entity state based on the local To-do items."""
|
||||||
self._attr_todo_items = [
|
todo_items = []
|
||||||
TodoItem(
|
for item in self._calendar.todos:
|
||||||
uid=item.uid,
|
if (due := item.due) and not isinstance(due, datetime.datetime):
|
||||||
summary=item.summary or "",
|
due -= datetime.timedelta(days=1)
|
||||||
status=ICS_TODO_STATUS_MAP.get(
|
todo_items.append(
|
||||||
item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION
|
TodoItem(
|
||||||
),
|
uid=item.uid,
|
||||||
due=item.due,
|
summary=item.summary or "",
|
||||||
description=item.description,
|
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:
|
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||||
"""Add an item to the To-do list."""
|
"""Add an item to the To-do list."""
|
||||||
todo = _convert_item(item)
|
todo = _convert_item(item)
|
||||||
TodoStore(self._calendar).add(todo)
|
TodoStore(self._calendar).add(todo)
|
||||||
await self._async_save()
|
await self.async_save()
|
||||||
await self.async_update_ha_state(force_refresh=True)
|
await self.async_update_ha_state(force_refresh=True)
|
||||||
|
|
||||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||||
"""Update an item to the To-do list."""
|
"""Update an item to the To-do list."""
|
||||||
todo = _convert_item(item)
|
todo = _convert_item(item)
|
||||||
TodoStore(self._calendar).edit(todo.uid, todo)
|
TodoStore(self._calendar).edit(todo.uid, todo)
|
||||||
await self._async_save()
|
await self.async_save()
|
||||||
await self.async_update_ha_state(force_refresh=True)
|
await self.async_update_ha_state(force_refresh=True)
|
||||||
|
|
||||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||||
|
@ -131,7 +163,7 @@ class LocalTodoListEntity(TodoListEntity):
|
||||||
store = TodoStore(self._calendar)
|
store = TodoStore(self._calendar)
|
||||||
for uid in uids:
|
for uid in uids:
|
||||||
store.delete(uid)
|
store.delete(uid)
|
||||||
await self._async_save()
|
await self.async_save()
|
||||||
await self.async_update_ha_state(force_refresh=True)
|
await self.async_update_ha_state(force_refresh=True)
|
||||||
|
|
||||||
async def async_move_todo_item(
|
async def async_move_todo_item(
|
||||||
|
@ -156,10 +188,10 @@ class LocalTodoListEntity(TodoListEntity):
|
||||||
if dst_idx > src_idx:
|
if dst_idx > src_idx:
|
||||||
dst_idx -= 1
|
dst_idx -= 1
|
||||||
todos.insert(dst_idx, src_item)
|
todos.insert(dst_idx, src_item)
|
||||||
await self._async_save()
|
await self.async_save()
|
||||||
await self.async_update_ha_state(force_refresh=True)
|
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."""
|
"""Persist the todo list to disk."""
|
||||||
content = IcsCalendarStream.calendar_to_ics(self._calendar)
|
content = IcsCalendarStream.calendar_to_ics(self._calendar)
|
||||||
await self._store.async_store(content)
|
await self._store.async_store(content)
|
||||||
|
|
|
@ -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([
|
||||||
|
])
|
||||||
|
# ---
|
|
@ -5,6 +5,7 @@ import textwrap
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
|
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
@ -628,13 +629,64 @@ async def test_move_item_previous_unknown(
|
||||||
),
|
),
|
||||||
"1",
|
"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(
|
async def test_parse_existing_ics(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
hass_ws_client: WebSocketGenerator,
|
hass_ws_client: WebSocketGenerator,
|
||||||
setup_integration: None,
|
setup_integration: None,
|
||||||
|
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
expected_state: str,
|
expected_state: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test parsing ics content."""
|
"""Test parsing ics content."""
|
||||||
|
@ -643,6 +695,9 @@ async def test_parse_existing_ics(
|
||||||
assert state
|
assert state
|
||||||
assert state.state == expected_state
|
assert state.state == expected_state
|
||||||
|
|
||||||
|
items = await ws_get_items()
|
||||||
|
assert items == snapshot
|
||||||
|
|
||||||
|
|
||||||
async def test_susbcribe(
|
async def test_susbcribe(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
|
Loading…
Reference in New Issue