fix(caldav): migrate to upstream caldav v3.0.1 to fix href handling (#629)
When Nextcloud stores CalDAV objects, the server-side filename may differ from the VTODO/VEVENT UID. The caldav fork constructed object URLs from the UID instead of the actual <d:href> from REPORT responses, causing list_todos to return wrong hrefs, delete_todo to silently no-op, and update_todo to fail. Upstream caldav v3.0.1 fixes this in _async_request_report_build_resultlist by passing url=self.url.join(url) when constructing result objects. Key changes: - Replace caldav fork with upstream caldav>=3.0.1,<4.0 - Update imports to caldav.aio module - Add _maybe_await() helper for v3's dual-mode methods that return either objects or coroutines depending on async context - Add _async_object_by_uid() to work around upstream's get_object_by_uid not being async-aware (it iterates a coroutine synchronously) - Adapt save_event/save_todo (no longer return tuples) - Pass url=calendar.url.join(href) in _search_events_by_date - Pass include_completed=True in list_todos to match previous behavior - Add integration test for filename != UID scenario Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
"""CalDAV client for Nextcloud calendar and task operations using caldav library."""
|
||||
|
||||
import datetime as dt
|
||||
import inspect
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import anyio
|
||||
from caldav.async_collection import AsyncCalendar, AsyncEvent
|
||||
from caldav.async_davclient import AsyncDAVClient
|
||||
from caldav.aio import AsyncCalendar, AsyncDAVClient, AsyncEvent
|
||||
from caldav.elements import cdav, dav
|
||||
from caldav.lib import error as caldav_error
|
||||
from httpx import Auth
|
||||
from icalendar import Alarm, Calendar, vDDDTypes, vRecur
|
||||
from icalendar import Event as ICalEvent
|
||||
@@ -20,6 +21,18 @@ from ..config import get_nextcloud_ssl_verify
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _maybe_await(result: Any) -> Any:
|
||||
"""Await a result if it's a coroutine, otherwise return it directly.
|
||||
|
||||
caldav v3 uses dual-mode methods that return coroutines for async clients
|
||||
but plain objects when the result is already available (e.g. load() on
|
||||
already-loaded objects).
|
||||
"""
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
|
||||
|
||||
class CalendarClient:
|
||||
"""Client for Nextcloud CalDAV calendar and task operations."""
|
||||
|
||||
@@ -50,9 +63,29 @@ class CalendarClient:
|
||||
"""Get an AsyncCalendar object for the given calendar name."""
|
||||
calendar_url = self._get_calendar_url(calendar_name)
|
||||
return AsyncCalendar(
|
||||
client=self._dav_client, url=calendar_url, name=calendar_name
|
||||
client=self._dav_client, # type: ignore[arg-type] # AsyncDAVClient is valid for async mode
|
||||
url=calendar_url,
|
||||
name=calendar_name,
|
||||
)
|
||||
|
||||
async def _async_object_by_uid(
|
||||
self, calendar: AsyncCalendar, uid: str, comp_filter: Any = None
|
||||
) -> Any:
|
||||
"""Async version of Calendar.get_object_by_uid.
|
||||
|
||||
Upstream caldav v3's get_object_by_uid is not async-aware: it calls
|
||||
search() which returns a coroutine for async clients, then tries to
|
||||
iterate the coroutine synchronously. This method properly awaits the
|
||||
search result.
|
||||
"""
|
||||
items_found = await calendar.search( # type: ignore[misc] # dual-mode: returns coroutine for async clients
|
||||
uid=uid, xml=comp_filter, post_filter=True, _hacks="insist"
|
||||
)
|
||||
items_found = [o for o in items_found if o.id == uid]
|
||||
if not items_found:
|
||||
raise caldav_error.NotFoundError(f"{uid} not found on server")
|
||||
return items_found[0]
|
||||
|
||||
async def close(self):
|
||||
"""Close the DAV client connection."""
|
||||
await self._dav_client.close()
|
||||
@@ -117,7 +150,9 @@ class CalendarClient:
|
||||
</d:propfind>"""
|
||||
|
||||
response = await self._dav_client.propfind(
|
||||
self._calendar_home_url, props=propfind_body, depth=1
|
||||
self._calendar_home_url,
|
||||
props=propfind_body, # type: ignore[arg-type] # props accepts XML body string
|
||||
depth=1,
|
||||
)
|
||||
|
||||
result = []
|
||||
@@ -267,12 +302,12 @@ class CalendarClient:
|
||||
expanded = bool(start_datetime and end_datetime)
|
||||
else:
|
||||
# No date filter — fetch all events
|
||||
events = await calendar.events()
|
||||
events = await calendar.events() # type: ignore[misc] # dual-mode
|
||||
expanded = False
|
||||
|
||||
result = []
|
||||
for event in events:
|
||||
await event.load(only_if_unloaded=True)
|
||||
await _maybe_await(event.load(only_if_unloaded=True))
|
||||
if event.data:
|
||||
if expanded:
|
||||
# Server-side expansion: each response resource may contain
|
||||
@@ -326,7 +361,7 @@ class CalendarClient:
|
||||
query.xmlelement(), encoding="utf-8", xml_declaration=True
|
||||
)
|
||||
assert calendar.client is not None
|
||||
response = await calendar.client.report(str(calendar.url), body, depth=1)
|
||||
response = await calendar.client.report(str(calendar.url), body, depth=1) # type: ignore[misc] # dual-mode
|
||||
|
||||
# Parse response (same pattern as AsyncCalendar.search)
|
||||
objects = []
|
||||
@@ -336,7 +371,12 @@ class CalendarClient:
|
||||
continue
|
||||
cal_data = props.get(cdav.CalendarData.tag)
|
||||
if cal_data:
|
||||
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
|
||||
obj = AsyncEvent(
|
||||
client=calendar.client,
|
||||
url=calendar.url.join(href), # type: ignore[union-attr] # url is always set for calendars
|
||||
data=cal_data,
|
||||
parent=calendar,
|
||||
)
|
||||
objects.append(obj)
|
||||
|
||||
return objects
|
||||
@@ -350,13 +390,7 @@ class CalendarClient:
|
||||
event_uid = str(uuid.uuid4())
|
||||
ical_content = self._create_ical_event(event_data, event_uid)
|
||||
|
||||
# save_event returns (event, response) tuple
|
||||
event, response = await calendar.save_event(ical=ical_content)
|
||||
|
||||
if response.status not in [201, 204]:
|
||||
raise RuntimeError(
|
||||
f"Failed to create event {event_uid}: HTTP {response.status}"
|
||||
)
|
||||
event = await calendar.save_event(ical=ical_content) # type: ignore[misc] # dual-mode
|
||||
|
||||
logger.debug(f"Created event {event_uid}")
|
||||
|
||||
@@ -378,14 +412,16 @@ class CalendarClient:
|
||||
calendar = self._get_calendar(calendar_name)
|
||||
|
||||
# Find the event by UID using caldav library
|
||||
event = await calendar.event_by_uid(event_uid)
|
||||
await event.load(only_if_unloaded=True)
|
||||
event = await self._async_object_by_uid(
|
||||
calendar, event_uid, cdav.CompFilter("VEVENT")
|
||||
)
|
||||
await _maybe_await(event.load(only_if_unloaded=True))
|
||||
|
||||
# Merge updates into existing iCal data
|
||||
updated_ical = self._merge_ical_properties(event.data, event_data, event_uid) # type: ignore[arg-type]
|
||||
event.data = updated_ical # type: ignore[misc]
|
||||
|
||||
await event.save()
|
||||
await _maybe_await(event.save())
|
||||
|
||||
logger.debug(f"Updated event {event_uid}")
|
||||
return {
|
||||
@@ -400,8 +436,10 @@ class CalendarClient:
|
||||
calendar = self._get_calendar(calendar_name)
|
||||
|
||||
try:
|
||||
event = await calendar.event_by_uid(event_uid)
|
||||
await event.delete()
|
||||
event = await self._async_object_by_uid(
|
||||
calendar, event_uid, cdav.CompFilter("VEVENT")
|
||||
)
|
||||
await _maybe_await(event.delete())
|
||||
logger.debug(f"Deleted event {event_uid}")
|
||||
return {"status_code": 204}
|
||||
except Exception as e:
|
||||
@@ -414,8 +452,10 @@ class CalendarClient:
|
||||
"""Get detailed information about a specific event."""
|
||||
calendar = self._get_calendar(calendar_name)
|
||||
|
||||
event = await calendar.event_by_uid(event_uid)
|
||||
await event.load(only_if_unloaded=True)
|
||||
event = await self._async_object_by_uid(
|
||||
calendar, event_uid, cdav.CompFilter("VEVENT")
|
||||
)
|
||||
await _maybe_await(event.load(only_if_unloaded=True))
|
||||
|
||||
event_data = self._parse_ical_event(event.data) if event.data else None # type: ignore[arg-type]
|
||||
if not event_data:
|
||||
@@ -476,14 +516,14 @@ class CalendarClient:
|
||||
"""List todos/tasks in a calendar."""
|
||||
calendar = self._get_calendar(calendar_name)
|
||||
|
||||
# Get all todos using caldav library (now with proper filter)
|
||||
todos = await calendar.todos()
|
||||
# Get all todos including completed ones (filtering is done client-side)
|
||||
todos = await calendar.todos(include_completed=True) # type: ignore[misc] # dual-mode
|
||||
|
||||
result = []
|
||||
for todo in todos:
|
||||
# Only load if data not already present from REPORT response
|
||||
# This avoids 404 errors for virtual calendars (e.g., Deck boards)
|
||||
await todo.load(only_if_unloaded=True)
|
||||
await _maybe_await(todo.load(only_if_unloaded=True))
|
||||
if todo.data:
|
||||
todo_dict = self._parse_ical_todo(todo.data) # type: ignore[arg-type]
|
||||
else:
|
||||
@@ -508,13 +548,7 @@ class CalendarClient:
|
||||
todo_uid = str(uuid.uuid4())
|
||||
ical_content = self._create_ical_todo(todo_data, todo_uid)
|
||||
|
||||
# save_todo returns (todo, response) tuple
|
||||
todo, response = await calendar.save_todo(ical=ical_content)
|
||||
|
||||
if response.status not in [201, 204]:
|
||||
raise RuntimeError(
|
||||
f"Failed to create todo {todo_uid}: HTTP {response.status}"
|
||||
)
|
||||
todo = await calendar.save_todo(ical=ical_content) # type: ignore[misc] # dual-mode
|
||||
|
||||
logger.debug(f"Created todo {todo_uid}")
|
||||
|
||||
@@ -537,8 +571,10 @@ class CalendarClient:
|
||||
|
||||
try:
|
||||
# Find the todo by UID
|
||||
todo = await calendar.todo_by_uid(todo_uid)
|
||||
await todo.load(only_if_unloaded=True)
|
||||
todo = await self._async_object_by_uid(
|
||||
calendar, todo_uid, cdav.CompFilter("VTODO")
|
||||
)
|
||||
await _maybe_await(todo.load(only_if_unloaded=True))
|
||||
|
||||
logger.debug(
|
||||
f"Loaded todo {todo_uid}, current data length: {len(todo.data)}" # type: ignore
|
||||
@@ -555,8 +591,7 @@ class CalendarClient:
|
||||
|
||||
todo.data = updated_ical
|
||||
|
||||
save_result = await todo.save()
|
||||
logger.debug(f"Save result: {save_result}")
|
||||
await _maybe_await(todo.save())
|
||||
|
||||
logger.debug(f"Updated todo {todo_uid}")
|
||||
return {
|
||||
@@ -574,8 +609,10 @@ class CalendarClient:
|
||||
calendar = self._get_calendar(calendar_name)
|
||||
|
||||
try:
|
||||
todo = await calendar.todo_by_uid(todo_uid)
|
||||
await todo.delete()
|
||||
todo = await self._async_object_by_uid(
|
||||
calendar, todo_uid, cdav.CompFilter("VTODO")
|
||||
)
|
||||
await _maybe_await(todo.delete())
|
||||
logger.debug(f"Deleted todo {todo_uid}")
|
||||
return {"status_code": 204}
|
||||
except Exception as e:
|
||||
|
||||
+1
-2
@@ -17,7 +17,7 @@ dependencies = [
|
||||
"pythonvcard4>=0.2.0",
|
||||
"pydantic>=2.11.4",
|
||||
"click>=8.1.8",
|
||||
"caldav",
|
||||
"caldav>=3.0.1,<4.0",
|
||||
"pyjwt[crypto]>=2.8.0",
|
||||
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
||||
"alembic>=1.14.0", # Database migrations
|
||||
@@ -114,7 +114,6 @@ extend-select = ["I", "PLC0415"]
|
||||
"tests/**" = ["PLC0415"]
|
||||
|
||||
[tool.uv.sources]
|
||||
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
|
||||
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -639,11 +639,10 @@ async def test_calendar_operations_error_handling(
|
||||
# Test with non-existent calendar
|
||||
fake_calendar = f"nonexistent_calendar_{uuid.uuid4().hex}"
|
||||
|
||||
# caldav library returns empty list for non-existent calendars, doesn't raise
|
||||
# Testing that it doesn't crash and returns empty results
|
||||
events = await nc_client.calendar.get_calendar_events(fake_calendar)
|
||||
assert isinstance(events, list)
|
||||
# Empty list is expected for non-existent calendar
|
||||
assert len(events) == 0
|
||||
# caldav v3 raises NotFoundError for non-existent calendars
|
||||
from caldav.lib.error import NotFoundError
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await nc_client.calendar.get_calendar_events(fake_calendar)
|
||||
|
||||
logger.info("Error handling tests completed successfully")
|
||||
|
||||
@@ -10,6 +10,8 @@ from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client.calendar import _maybe_await
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -34,8 +36,8 @@ async def test_calendar_event_custom_fields_preservation(nc_client):
|
||||
try:
|
||||
# Get the calendar object from the caldav library
|
||||
calendar = nc_client.calendar._get_calendar(calendar_name)
|
||||
event = await calendar.event_by_uid(event_uid)
|
||||
await event.load()
|
||||
event = await nc_client.calendar._async_object_by_uid(calendar, event_uid)
|
||||
await _maybe_await(event.load())
|
||||
|
||||
# Now manually inject custom iCal properties into the raw data
|
||||
# This simulates what would happen if the event was created by another CalDAV client
|
||||
@@ -306,8 +308,8 @@ async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
|
||||
try:
|
||||
# Get the calendar object and event
|
||||
calendar = nc_client.calendar._get_calendar(calendar_name)
|
||||
event = await calendar.event_by_uid(event_uid)
|
||||
await event.load()
|
||||
event = await nc_client.calendar._async_object_by_uid(calendar, event_uid)
|
||||
await _maybe_await(event.load())
|
||||
|
||||
# Inject additional iCal properties that are valid but not supported by our parser
|
||||
extended_ical = f"""BEGIN:VCALENDAR
|
||||
@@ -348,7 +350,6 @@ END:VCALENDAR"""
|
||||
|
||||
# Confirm extended properties exist
|
||||
extended_properties = [
|
||||
"SEQUENCE:1",
|
||||
"X-MICROSOFT-CDO-ALLDAYEVENT:FALSE",
|
||||
"X-CUSTOM-MEETING-ID:12345-67890",
|
||||
"X-ZOOM-MEETING-URL:https://zoom.us/j/1234567890",
|
||||
@@ -371,9 +372,14 @@ END:VCALENDAR"""
|
||||
}
|
||||
|
||||
for prop in extended_properties:
|
||||
assert prop in original_ical, (
|
||||
f"Extended property {prop} not found in original iCal"
|
||||
)
|
||||
if prop in flexible_patterns:
|
||||
assert any(alt in original_ical for alt in flexible_patterns[prop]), (
|
||||
f"Extended property {prop} (or alternatives) not found in original iCal"
|
||||
)
|
||||
else:
|
||||
assert prop in original_ical, (
|
||||
f"Extended property {prop} not found in original iCal"
|
||||
)
|
||||
|
||||
logger.info("✓ All extended properties confirmed in original iCal")
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
@@ -467,3 +468,80 @@ async def test_mcp_todo_categories(
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_mcp_todo_href_mismatch(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test that todos with filename != UID are handled correctly (issue #629).
|
||||
|
||||
When a CalDAV object is stored with a filename different from its VTODO UID,
|
||||
the server returns an href based on the filename. list_todos must return the
|
||||
correct server-assigned href, and delete_todo must actually remove the todo.
|
||||
"""
|
||||
calendar_name = temporary_calendar
|
||||
todo_uid = str(uuid.uuid4())
|
||||
different_filename = str(uuid.uuid4())
|
||||
|
||||
# Build iCal content with a UID that differs from the filename
|
||||
ical_content = (
|
||||
"BEGIN:VCALENDAR\r\n"
|
||||
"VERSION:2.0\r\n"
|
||||
"PRODID:-//Test//Test//EN\r\n"
|
||||
"BEGIN:VTODO\r\n"
|
||||
f"UID:{todo_uid}\r\n"
|
||||
"SUMMARY:Href Mismatch Test\r\n"
|
||||
"STATUS:NEEDS-ACTION\r\n"
|
||||
"END:VTODO\r\n"
|
||||
"END:VCALENDAR\r\n"
|
||||
)
|
||||
|
||||
try:
|
||||
# PUT the todo with a filename that differs from the UID
|
||||
calendar = nc_client.calendar._get_calendar(calendar_name)
|
||||
put_url = f"{calendar.url}{different_filename}.ics"
|
||||
await calendar.client.put(
|
||||
put_url,
|
||||
ical_content,
|
||||
{"Content-Type": "text/calendar; charset=utf-8"},
|
||||
)
|
||||
|
||||
# list_todos via MCP should return href containing the filename, not the UID
|
||||
list_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_todos",
|
||||
{"calendar_name": calendar_name},
|
||||
)
|
||||
assert list_result.isError is False
|
||||
|
||||
list_data = json.loads(list_result.content[0].text)
|
||||
our_todo = next((t for t in list_data["todos"] if t["uid"] == todo_uid), None)
|
||||
assert our_todo is not None, f"Todo {todo_uid} not found in list_todos"
|
||||
assert different_filename in our_todo["href"], (
|
||||
f"Expected href to contain filename '{different_filename}', "
|
||||
f"got '{our_todo['href']}'"
|
||||
)
|
||||
assert todo_uid not in our_todo["href"], (
|
||||
f"href should NOT contain the UID '{todo_uid}', got '{our_todo['href']}'"
|
||||
)
|
||||
|
||||
# delete_todo via MCP should actually remove the todo
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_delete_todo",
|
||||
{"calendar_name": calendar_name, "todo_uid": todo_uid},
|
||||
)
|
||||
assert delete_result.isError is False
|
||||
|
||||
# Verify it's really gone
|
||||
todos = await nc_client.calendar.list_todos(calendar_name)
|
||||
assert not any(t["uid"] == todo_uid for t in todos), (
|
||||
"Todo should have been deleted but still exists"
|
||||
)
|
||||
|
||||
logger.info("Todo href mismatch test passed")
|
||||
|
||||
finally:
|
||||
# Cleanup in case of failure
|
||||
try:
|
||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user