feat(calendar): list external read-only (subscribed) calendars
list_calendars only kept PROPFIND responses whose resourcetype was <c:calendar>, so external subscriptions (webcal/ICS feeds) — exposed by Nextcloud as <cs:subscribed> collections — were silently dropped and never appeared in nc_calendar_list_calendars (issue #830). - Construct the calendar DAV client with `X-NC-CalDAV-Webcal-Caching: On` so Nextcloud exposes subscriptions as queryable CachedSubscription calendars, making their events readable through the existing event/search tools (the same mechanism Evolution/KDE desktop clients use). - In list_calendars, override that header to "Off" so subscriptions are returned as cs:subscribed collections with their cs:source href, then parse responses whose resourcetype is c:calendar OR cs:subscribed. Subscriptions are reported with read_only=True and their source feed URL; their color is read from the Apple iCal namespace as a fallback. - Send the custom PROPFIND markup via `body=` instead of `props=`: under caldav 3.x `props=` expects a list of property names and discards a raw XML string, producing an empty <prop/>. This also restores display_name, description and color which were previously falling back to defaults. - Add `read_only` and `source` fields to the Calendar model. Adds unit tests for subscription parsing, the body/header wiring, and the webcal-caching header; extends the integration test to assert the new fields.
This commit is contained in:
+5
-2
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
The server provides comprehensive calendar integration through CalDAV, enabling you to:
|
The server provides comprehensive calendar integration through CalDAV, enabling you to:
|
||||||
|
|
||||||
- List all available calendars
|
- List all available calendars, including external read-only subscriptions
|
||||||
- Create, read, update, and delete calendar events
|
- Create, read, update, and delete calendar events
|
||||||
- Handle recurring events with RRULE support
|
- Handle recurring events with RRULE support
|
||||||
- Manage event reminders and notifications
|
- Manage event reminders and notifications
|
||||||
@@ -31,7 +31,10 @@ The server provides comprehensive calendar integration through CalDAV, enabling
|
|||||||
**Usage Examples:**
|
**Usage Examples:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# List available calendars
|
# List available calendars. External subscriptions (webcal/ICS feeds) are
|
||||||
|
# included and reported with read_only=True and a `source` URL pointing at the
|
||||||
|
# upstream feed. Their events are readable through the normal event tools, but
|
||||||
|
# attempts to modify them will be rejected by Nextcloud.
|
||||||
calendars = await nc_calendar_list_calendars()
|
calendars = await nc_calendar_list_calendars()
|
||||||
|
|
||||||
# Create a simple event
|
# Create a simple event
|
||||||
|
|||||||
@@ -80,11 +80,20 @@ class CalendarClient:
|
|||||||
elif token is not None:
|
elif token is not None:
|
||||||
auth_kwargs = {"password": token, "auth_type": "bearer"}
|
auth_kwargs = {"password": token, "auth_type": "bearer"}
|
||||||
|
|
||||||
# AsyncDAVClient needs the full base URL for proper URL construction
|
# AsyncDAVClient needs the full base URL for proper URL construction.
|
||||||
|
#
|
||||||
|
# The X-NC-CalDAV-Webcal-Caching header makes Nextcloud expose external
|
||||||
|
# subscriptions (webcal/ICS feeds) as regular, queryable calendars
|
||||||
|
# (CachedSubscription) instead of opaque cs:subscribed collections, so
|
||||||
|
# their events become readable through the normal event/search tools —
|
||||||
|
# the same mechanism desktop clients (Evolution/KDE) rely on (issue #830).
|
||||||
|
# list_calendars() overrides this header to "Off" on its own PROPFIND so
|
||||||
|
# it can still detect subscriptions and flag them read-only.
|
||||||
self._dav_client = AsyncDAVClient(
|
self._dav_client = AsyncDAVClient(
|
||||||
url=f"{base_url}/remote.php/dav/",
|
url=f"{base_url}/remote.php/dav/",
|
||||||
username=auth_username,
|
username=auth_username,
|
||||||
ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to niquests which accepts SSLContext
|
ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to niquests which accepts SSLContext
|
||||||
|
headers={"X-NC-CalDAV-Webcal-Caching": "On"},
|
||||||
**auth_kwargs,
|
**auth_kwargs,
|
||||||
)
|
)
|
||||||
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
|
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
|
||||||
@@ -182,25 +191,46 @@ class CalendarClient:
|
|||||||
# ============= Calendar Operations =============
|
# ============= Calendar Operations =============
|
||||||
|
|
||||||
async def list_calendars(self) -> list[dict[str, Any]]:
|
async def list_calendars(self) -> list[dict[str, Any]]:
|
||||||
"""List all available calendars for the user."""
|
"""List all available calendars for the user.
|
||||||
|
|
||||||
|
Returns both regular calendars and external read-only subscriptions
|
||||||
|
(webcal/ICS feeds). Subscriptions are reported with ``read_only=True``
|
||||||
|
and a ``source`` URL pointing at the upstream feed (issue #830).
|
||||||
|
"""
|
||||||
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
|
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
|
||||||
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
|
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
|
||||||
# Apple iCal namespace which Nextcloud doesn't recognize.
|
# Apple iCal namespace which Nextcloud doesn't recognize.
|
||||||
|
#
|
||||||
|
# cs:source / ical:calendar-color are requested to surface external
|
||||||
|
# subscriptions: Nextcloud exposes those as cs:subscribed collections
|
||||||
|
# carrying a cs:source href and an Apple-namespace color.
|
||||||
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ical="http://apple.com/ns/ical/">
|
||||||
<d:prop>
|
<d:prop>
|
||||||
<d:displayname/>
|
<d:displayname/>
|
||||||
<d:resourcetype/>
|
<d:resourcetype/>
|
||||||
<cs:getctag/>
|
<cs:getctag/>
|
||||||
<c:calendar-description/>
|
<c:calendar-description/>
|
||||||
<cs:calendar-color/>
|
<cs:calendar-color/>
|
||||||
|
<ical:calendar-color/>
|
||||||
|
<cs:source/>
|
||||||
</d:prop>
|
</d:prop>
|
||||||
</d:propfind>"""
|
</d:propfind>"""
|
||||||
|
|
||||||
|
# Override the client-wide webcal-caching header to "Off" for this
|
||||||
|
# PROPFIND so subscriptions are returned as cs:subscribed collections
|
||||||
|
# (with cs:source) and can be detected and flagged read-only. With the
|
||||||
|
# header "On" they would masquerade as regular calendars, hiding the
|
||||||
|
# source URL. Event reads keep the client-wide "On" so they stay
|
||||||
|
# queryable (see __init__).
|
||||||
|
# Pass the request XML via ``body``, not ``props``: caldav's ``props``
|
||||||
|
# expects a list of property *names* and would build its own body
|
||||||
|
# (discarding this custom CalendarServer/Apple-namespace markup).
|
||||||
response = await self._dav_client.propfind(
|
response = await self._dav_client.propfind(
|
||||||
self._calendar_home_url,
|
self._calendar_home_url,
|
||||||
props=propfind_body, # type: ignore[arg-type] # props accepts XML body string
|
body=propfind_body,
|
||||||
depth=1,
|
depth=1,
|
||||||
|
headers={"X-NC-CalDAV-Webcal-Caching": "Off"},
|
||||||
)
|
)
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
@@ -211,57 +241,79 @@ class CalendarClient:
|
|||||||
"d": "DAV:",
|
"d": "DAV:",
|
||||||
"cs": "http://calendarserver.org/ns/",
|
"cs": "http://calendarserver.org/ns/",
|
||||||
"c": "urn:ietf:params:xml:ns:caldav",
|
"c": "urn:ietf:params:xml:ns:caldav",
|
||||||
|
"ical": "http://apple.com/ns/ical/",
|
||||||
}
|
}
|
||||||
|
|
||||||
for response_elem in tree.findall(".//d:response", ns):
|
for response_elem in tree.findall(".//d:response", ns):
|
||||||
# Check if this is a calendar (has resourcetype/calendar)
|
# A response is a calendar if it is a regular calendar collection
|
||||||
|
# (c:calendar) or an external subscription (cs:subscribed).
|
||||||
resourcetype = response_elem.find(".//d:resourcetype", ns)
|
resourcetype = response_elem.find(".//d:resourcetype", ns)
|
||||||
if (
|
if resourcetype is None:
|
||||||
resourcetype is not None
|
continue
|
||||||
and resourcetype.find(".//c:calendar", ns) is not None
|
is_calendar = resourcetype.find(".//c:calendar", ns) is not None
|
||||||
):
|
is_subscribed = resourcetype.find(".//cs:subscribed", ns) is not None
|
||||||
href = response_elem.find("./d:href", ns)
|
if not (is_calendar or is_subscribed):
|
||||||
if href is not None and href.text:
|
continue
|
||||||
calendar_url = href.text
|
|
||||||
# Extract calendar name from URL
|
|
||||||
calendar_name = calendar_url.rstrip("/").split("/")[-1]
|
|
||||||
|
|
||||||
# Skip if this is the calendar home itself
|
href = response_elem.find("./d:href", ns)
|
||||||
if calendar_url.rstrip("/") == self._calendar_home_url.rstrip("/"):
|
if href is None or not href.text:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
display_name_elem = response_elem.find(".//d:displayname", ns)
|
calendar_url = href.text
|
||||||
display_name = (
|
# Extract calendar name from URL
|
||||||
display_name_elem.text
|
calendar_name = calendar_url.rstrip("/").split("/")[-1]
|
||||||
if display_name_elem is not None and display_name_elem.text
|
|
||||||
else calendar_name
|
|
||||||
)
|
|
||||||
|
|
||||||
description_elem = response_elem.find(
|
# Skip if this is the calendar home itself
|
||||||
".//c:calendar-description", ns
|
if calendar_url.rstrip("/") == self._calendar_home_url.rstrip("/"):
|
||||||
)
|
continue
|
||||||
description = (
|
|
||||||
description_elem.text
|
|
||||||
if description_elem is not None and description_elem.text
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
color_elem = response_elem.find(".//cs:calendar-color", ns)
|
display_name_elem = response_elem.find(".//d:displayname", ns)
|
||||||
color = (
|
display_name = (
|
||||||
color_elem.text
|
display_name_elem.text
|
||||||
if color_elem is not None and color_elem.text
|
if display_name_elem is not None and display_name_elem.text
|
||||||
else "#1976D2"
|
else calendar_name
|
||||||
)
|
)
|
||||||
|
|
||||||
result.append(
|
description_elem = response_elem.find(".//c:calendar-description", ns)
|
||||||
{
|
description = (
|
||||||
"name": calendar_name,
|
description_elem.text
|
||||||
"display_name": display_name,
|
if description_elem is not None and description_elem.text
|
||||||
"description": description,
|
else ""
|
||||||
"color": color,
|
)
|
||||||
"href": calendar_url,
|
|
||||||
}
|
# Regular calendars expose cs:calendar-color; subscriptions store
|
||||||
)
|
# their color under the Apple iCal namespace.
|
||||||
|
color_elem = response_elem.find(".//cs:calendar-color", ns)
|
||||||
|
if color_elem is None or not color_elem.text:
|
||||||
|
color_elem = response_elem.find(".//ical:calendar-color", ns)
|
||||||
|
color = (
|
||||||
|
color_elem.text
|
||||||
|
if color_elem is not None and color_elem.text
|
||||||
|
else "#1976D2"
|
||||||
|
)
|
||||||
|
|
||||||
|
# External subscriptions carry a cs:source href pointing at the
|
||||||
|
# upstream feed and are read-only.
|
||||||
|
source = None
|
||||||
|
source_elem = response_elem.find(".//cs:source", ns)
|
||||||
|
if source_elem is not None:
|
||||||
|
source_href = source_elem.find("./d:href", ns)
|
||||||
|
if source_href is not None and source_href.text:
|
||||||
|
source = source_href.text
|
||||||
|
elif source_elem.text and source_elem.text.strip():
|
||||||
|
source = source_elem.text.strip()
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"name": calendar_name,
|
||||||
|
"display_name": display_name,
|
||||||
|
"description": description,
|
||||||
|
"color": color,
|
||||||
|
"href": calendar_url,
|
||||||
|
"read_only": is_subscribed,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug("Found %s calendars", len(result))
|
logger.debug("Found %s calendars", len(result))
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ class Calendar(BaseModel):
|
|||||||
timezone: Optional[str] = Field(None, description="Calendar timezone")
|
timezone: Optional[str] = Field(None, description="Calendar timezone")
|
||||||
enabled: bool = Field(default=True, description="Whether calendar is enabled")
|
enabled: bool = Field(default=True, description="Whether calendar is enabled")
|
||||||
ctag: Optional[str] = Field(None, description="Calendar tag for synchronization")
|
ctag: Optional[str] = Field(None, description="Calendar tag for synchronization")
|
||||||
|
read_only: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether the calendar is read-only (e.g. an external subscription)",
|
||||||
|
)
|
||||||
|
source: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Source URL of an external/subscribed read-only calendar",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalendarEventSummary(BaseModel):
|
class CalendarEventSummary(BaseModel):
|
||||||
|
|||||||
@@ -89,8 +89,18 @@ async def test_list_calendars(nc_client: NextcloudClient):
|
|||||||
# Optional fields
|
# Optional fields
|
||||||
assert "description" in calendar
|
assert "description" in calendar
|
||||||
assert "color" in calendar
|
assert "color" in calendar
|
||||||
|
# External subscription metadata (issue #830): always present, with
|
||||||
|
# read_only=True / a source URL for subscribed calendars.
|
||||||
|
assert "read_only" in calendar
|
||||||
|
assert isinstance(calendar["read_only"], bool)
|
||||||
|
assert "source" in calendar
|
||||||
|
|
||||||
logger.info("Calendar: %s - %s", calendar["name"], calendar["display_name"])
|
logger.info(
|
||||||
|
"Calendar: %s - %s (read_only=%s)",
|
||||||
|
calendar["name"],
|
||||||
|
calendar["display_name"],
|
||||||
|
calendar["read_only"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_create_and_delete_event(
|
async def test_create_and_delete_event(
|
||||||
|
|||||||
@@ -142,3 +142,138 @@ def test_auth_username_defaults_to_username(mocker):
|
|||||||
CalendarClient("https://cloud.example.org", "alice", password="app-pw")
|
CalendarClient("https://cloud.example.org", "alice", password="app-pw")
|
||||||
|
|
||||||
assert mock_dav_client.call_args.kwargs["username"] == "alice"
|
assert mock_dav_client.call_args.kwargs["username"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webcal_caching_header_enabled_on_client(mocker):
|
||||||
|
"""The client is constructed with the webcal-caching header turned on.
|
||||||
|
|
||||||
|
This is what makes Nextcloud expose external subscriptions as queryable
|
||||||
|
CachedSubscription calendars, so their events are readable through the
|
||||||
|
normal event/search tools (issue #830).
|
||||||
|
"""
|
||||||
|
mock_dav_client = mocker.patch(
|
||||||
|
"nextcloud_mcp_server.client.calendar.AsyncDAVClient"
|
||||||
|
)
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.client.calendar import CalendarClient
|
||||||
|
|
||||||
|
CalendarClient("https://cloud.example.org", "alice", password="app-pw")
|
||||||
|
|
||||||
|
headers = mock_dav_client.call_args.kwargs["headers"]
|
||||||
|
assert headers["X-NC-CalDAV-Webcal-Caching"] == "On"
|
||||||
|
|
||||||
|
|
||||||
|
# --- list_calendars: regular + external subscription parsing (issue #830) ---
|
||||||
|
|
||||||
|
# A multistatus body with the calendar home, one regular calendar, and one
|
||||||
|
# external subscription (cs:subscribed) carrying a cs:source href and an
|
||||||
|
# Apple-namespace color.
|
||||||
|
_LIST_CALENDARS_MULTISTATUS = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ical="http://apple.com/ns/ical/">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/calendars/alice/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/calendars/alice/personal/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname>Personal</d:displayname>
|
||||||
|
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
|
||||||
|
<c:calendar-description>My personal calendar</c:calendar-description>
|
||||||
|
<cs:calendar-color>#FF0000</cs:calendar-color>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/calendars/alice/holidays/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname>Public Holidays</d:displayname>
|
||||||
|
<d:resourcetype><d:collection/><cs:subscribed/></d:resourcetype>
|
||||||
|
<ical:calendar-color>#00FF00</ical:calendar-color>
|
||||||
|
<cs:source><d:href>https://example.com/holidays.ics</d:href></cs:source>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_client_with_propfind(mocker, raw_xml: str):
|
||||||
|
"""Build a CalendarClient whose DAV client returns ``raw_xml`` from PROPFIND."""
|
||||||
|
mock_dav_client = mocker.patch(
|
||||||
|
"nextcloud_mcp_server.client.calendar.AsyncDAVClient"
|
||||||
|
)
|
||||||
|
instance = mock_dav_client.return_value
|
||||||
|
instance.propfind = mocker.AsyncMock(return_value=mocker.Mock(raw=raw_xml))
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.client.calendar import CalendarClient
|
||||||
|
|
||||||
|
client = CalendarClient("https://cloud.example.org", "alice", password="app-pw")
|
||||||
|
return client, instance
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_calendars_includes_external_subscription(mocker):
|
||||||
|
"""External subscriptions are returned alongside regular calendars and are
|
||||||
|
flagged read-only with their source feed URL (issue #830).
|
||||||
|
"""
|
||||||
|
client, _ = _calendar_client_with_propfind(mocker, _LIST_CALENDARS_MULTISTATUS)
|
||||||
|
|
||||||
|
calendars = await client.list_calendars()
|
||||||
|
|
||||||
|
by_name = {cal["name"]: cal for cal in calendars}
|
||||||
|
# The calendar home (plain collection) is not reported.
|
||||||
|
assert set(by_name) == {"personal", "holidays"}
|
||||||
|
|
||||||
|
personal = by_name["personal"]
|
||||||
|
assert personal["display_name"] == "Personal"
|
||||||
|
assert personal["description"] == "My personal calendar"
|
||||||
|
assert personal["color"] == "#FF0000"
|
||||||
|
assert personal["read_only"] is False
|
||||||
|
assert personal["source"] is None
|
||||||
|
|
||||||
|
holidays = by_name["holidays"]
|
||||||
|
assert holidays["display_name"] == "Public Holidays"
|
||||||
|
assert holidays["read_only"] is True
|
||||||
|
assert holidays["source"] == "https://example.com/holidays.ics"
|
||||||
|
# Subscriptions store their color under the Apple iCal namespace.
|
||||||
|
assert holidays["color"] == "#00FF00"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_calendars_disables_webcal_caching_for_propfind(mocker):
|
||||||
|
"""The listing PROPFIND overrides the client-wide header to "Off" so
|
||||||
|
subscriptions surface as cs:subscribed (with a source) rather than as
|
||||||
|
opaque regular calendars.
|
||||||
|
"""
|
||||||
|
client, instance = _calendar_client_with_propfind(
|
||||||
|
mocker, _LIST_CALENDARS_MULTISTATUS
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.list_calendars()
|
||||||
|
|
||||||
|
kwargs = instance.propfind.call_args.kwargs
|
||||||
|
assert kwargs["headers"]["X-NC-CalDAV-Webcal-Caching"] == "Off"
|
||||||
|
# The custom property XML must travel as ``body`` — caldav's ``props=``
|
||||||
|
# expects a list of property names and would discard a raw XML string,
|
||||||
|
# sending an empty <prop/> that returns neither resourcetype nor cs:source.
|
||||||
|
assert "cs:source" in kwargs["body"]
|
||||||
|
assert "props" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_calendars_model_round_trip(mocker):
|
||||||
|
"""The dicts returned by list_calendars validate against the Calendar model,
|
||||||
|
mirroring the server's ``Calendar(**cal_data)`` mapping.
|
||||||
|
"""
|
||||||
|
client, _ = _calendar_client_with_propfind(mocker, _LIST_CALENDARS_MULTISTATUS)
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.models.calendar import Calendar
|
||||||
|
|
||||||
|
calendars = [Calendar(**cal) for cal in await client.list_calendars()]
|
||||||
|
holidays = next(c for c in calendars if c.name == "holidays")
|
||||||
|
assert holidays.read_only is True
|
||||||
|
assert holidays.source == "https://example.com/holidays.ics"
|
||||||
|
|||||||
Reference in New Issue
Block a user