55import logging
66import uuid
77from typing import Any
8+ from urllib .parse import unquote
89from zoneinfo import ZoneInfo , ZoneInfoNotFoundError
910
1011import anyio
12+ import httpx
1113import recurring_ical_events
1214from caldav .aio import AsyncCalendar , AsyncDAVClient , AsyncEvent
1315from caldav .elements import cdav , dav
@@ -56,7 +58,7 @@ def __init__(
5658
5759 Args:
5860 base_url: Nextcloud base URL
59- username: Nextcloud username (UID) — used for DAV path construction
61+ username: Nextcloud username (UID) used as the DAV path fallback
6062 auth_username: Credential identity (loginName) the app password
6163 authenticates against; defaults to ``username``. Differs from
6264 the UID for OIDC-provisioned users.
@@ -68,10 +70,11 @@ def __init__(
6870 """
6971 self .username = username
7072 self .base_url = base_url
71- # The UID (``username``) drives DAV path construction; the loginName
72- # (``auth_username``) is the credential the app password authenticates
73- # against. They differ for OIDC-provisioned users. Defaults to the UID
74- # so existing single-user / OAuth callers are unchanged.
73+ # The UID (``username``) is the DAV path fallback until principal
74+ # discovery succeeds; the loginName (``auth_username``) is the
75+ # credential the app password authenticates against. They differ for
76+ # OIDC-provisioned users. Defaults to the UID so existing single-user /
77+ # OAuth callers are unchanged.
7578 auth_username = auth_username or username
7679
7780 auth_kwargs : dict [str , Any ] = {}
@@ -97,6 +100,85 @@ def __init__(
97100 ** auth_kwargs ,
98101 )
99102 self ._calendar_home_url = f"{ base_url } /remote.php/dav/calendars/{ username } /"
103+ self ._principal_resolved = False
104+
105+ def _calendar_home_url_from_home_set (self , home_set : Any ) -> str | None :
106+ """Normalize a caldav CalendarSet or URL into an absolute home URL."""
107+ if home_set is None :
108+ return None
109+
110+ home_url = getattr (home_set , "url" , home_set )
111+ if home_url is None :
112+ return None
113+
114+ home_url = str (home_url )
115+ if not home_url :
116+ return None
117+ if home_url .startswith ("/" ):
118+ home_url = f"{ self .base_url } { home_url } "
119+ if not home_url .endswith ("/" ):
120+ home_url = f"{ home_url } /"
121+ return home_url
122+
123+ async def _calendar_home_url_from_principal (self , principal : Any ) -> str | None :
124+ """Resolve calendar-home-set without using caldav's async-unsafe property."""
125+ get_property = getattr (principal , "get_property" , None )
126+ if get_property is not None :
127+ try :
128+ home_set = await _maybe_await (get_property (cdav .CalendarHomeSet ()))
129+ calendar_home_url = self ._calendar_home_url_from_home_set (home_set )
130+ if calendar_home_url :
131+ return calendar_home_url
132+ except (caldav_error .DAVError , AttributeError , TypeError , ValueError ) as e :
133+ logger .warning (
134+ "CalDAV calendar-home-set discovery failed; deriving from "
135+ "principal URL: %s" ,
136+ e ,
137+ )
138+
139+ try :
140+ home_set = getattr (principal , "calendar_home_set" , None )
141+ home_set = await _maybe_await (home_set )
142+ return self ._calendar_home_url_from_home_set (home_set )
143+ except (AttributeError , TypeError , ValueError ) as e :
144+ logger .warning (
145+ "CalDAV calendar-home-set property unavailable; deriving from "
146+ "principal URL: %s" ,
147+ e ,
148+ )
149+ return None
150+
151+ async def _ensure_calendar_home (self ) -> None :
152+ """Discover and cache the authenticated user's CalDAV calendar home."""
153+ if self ._principal_resolved :
154+ return
155+
156+ try :
157+ get_principal = getattr (self ._dav_client , "get_principal" , None )
158+ if get_principal is None :
159+ principal = await _maybe_await (self ._dav_client .principal ())
160+ else :
161+ principal = await _maybe_await (get_principal ())
162+
163+ calendar_home_url = await self ._calendar_home_url_from_principal (principal )
164+ if calendar_home_url :
165+ self ._calendar_home_url = calendar_home_url
166+ self ._principal_resolved = True
167+ return
168+
169+ principal_url = getattr (principal , "url" , None )
170+ if principal_url is None :
171+ raise ValueError ("CalDAV principal discovery returned no URL" )
172+ principal_id = unquote (str (principal_url ).rstrip ("/" ).split ("/" )[- 1 ])
173+ if principal_id :
174+ self ._calendar_home_url = (
175+ f"{ self .base_url } /remote.php/dav/calendars/{ principal_id } /"
176+ )
177+ self ._principal_resolved = True
178+ except (caldav_error .DAVError , httpx .HTTPError , ValueError ) as e :
179+ logger .warning (
180+ "CalDAV principal discovery failed; using username path: %s" , e
181+ )
100182
101183 def _get_calendar_url (self , calendar_name : str ) -> str :
102184 """Get the full URL for a calendar."""
@@ -197,6 +279,7 @@ async def list_calendars(self) -> list[dict[str, Any]]:
197279 (webcal/ICS feeds). Subscriptions are reported with ``read_only=True``
198280 and a ``source`` URL pointing at the upstream feed (issue #830).
199281 """
282+ await self ._ensure_calendar_home ()
200283 # Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
201284 # caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
202285 # Apple iCal namespace which Nextcloud doesn't recognize.
@@ -326,13 +409,12 @@ async def create_calendar(
326409 color : str = "#1976D2" ,
327410 ) -> dict [str , Any ]:
328411 """Create a new calendar with retry on 429 errors."""
412+ await self ._ensure_calendar_home ()
329413 # Use custom MKCALENDAR XML instead of caldav library's make_calendar() due to:
330414 # 1. Missing CalendarServer namespace (cs:) in caldav's nsmap
331415 # 2. caldav's CalendarColor uses Apple iCal namespace, not cs:calendar-color
332416 # 3. make_calendar() doesn't support calendar-description or calendar-color params
333- calendar_url = (
334- f"{ self .base_url } /remote.php/dav/calendars/{ self .username } /{ calendar_name } /"
335- )
417+ calendar_url = self ._get_calendar_url (calendar_name )
336418
337419 mkcalendar_body = f"""<?xml version="1.0" encoding="utf-8"?>
338420<mkcalendar xmlns="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
@@ -372,10 +454,9 @@ async def create_calendar(
372454
373455 async def delete_calendar (self , calendar_name : str ) -> dict [str , Any ]:
374456 """Delete a calendar."""
457+ await self ._ensure_calendar_home ()
375458 # Use absolute URL for deletion
376- calendar_url = (
377- f"{ self .base_url } /remote.php/dav/calendars/{ self .username } /{ calendar_name } /"
378- )
459+ calendar_url = self ._get_calendar_url (calendar_name )
379460 await self ._dav_client .delete (calendar_url )
380461
381462 logger .debug ("Deleted calendar: %s" , calendar_name )
@@ -391,6 +472,7 @@ async def get_calendar_events(
391472 limit : int = 50 ,
392473 ) -> list [dict [str , Any ]]:
393474 """List events in a calendar within date range."""
475+ await self ._ensure_calendar_home ()
394476 calendar = self ._get_calendar (calendar_name )
395477
396478 if start_datetime or end_datetime :
@@ -531,6 +613,7 @@ async def create_event(
531613 self , calendar_name : str , event_data : dict [str , Any ]
532614 ) -> dict [str , Any ]:
533615 """Create a new calendar event."""
616+ await self ._ensure_calendar_home ()
534617 calendar = self ._get_calendar (calendar_name )
535618
536619 event_uid = str (uuid .uuid4 ())
@@ -556,6 +639,7 @@ async def update_event(
556639 etag : str = "" ,
557640 ) -> dict [str , Any ]:
558641 """Update an existing calendar event."""
642+ await self ._ensure_calendar_home ()
559643 calendar = self ._get_calendar (calendar_name )
560644
561645 # Find the event by UID using caldav library
@@ -580,6 +664,7 @@ async def update_event(
580664
581665 async def delete_event (self , calendar_name : str , event_uid : str ) -> dict [str , Any ]:
582666 """Delete a calendar event."""
667+ await self ._ensure_calendar_home ()
583668 calendar = self ._get_calendar (calendar_name )
584669
585670 try :
@@ -597,6 +682,7 @@ async def get_event(
597682 self , calendar_name : str , event_uid : str
598683 ) -> tuple [dict [str , Any ], str ]:
599684 """Get detailed information about a specific event."""
685+ await self ._ensure_calendar_home ()
600686 calendar = self ._get_calendar (calendar_name )
601687
602688 event = await self ._async_object_by_uid (
@@ -621,6 +707,7 @@ async def search_events_across_calendars(
621707 filters : dict [str , Any ] | None = None ,
622708 ) -> list [dict [str , Any ]]:
623709 """Search events across all calendars with advanced filtering."""
710+ await self ._ensure_calendar_home ()
624711 try :
625712 calendars = await self .list_calendars ()
626713 all_events = []
@@ -661,6 +748,7 @@ async def list_todos(
661748 self , calendar_name : str , filters : dict [str , Any ] | None = None
662749 ) -> list [dict [str , Any ]]:
663750 """List todos/tasks in a calendar."""
751+ await self ._ensure_calendar_home ()
664752 calendar = self ._get_calendar (calendar_name )
665753
666754 # Get all todos including completed ones (filtering is done client-side)
@@ -690,6 +778,7 @@ async def create_todo(
690778 self , calendar_name : str , todo_data : dict [str , Any ]
691779 ) -> dict [str , Any ]:
692780 """Create a new todo/task."""
781+ await self ._ensure_calendar_home ()
693782 calendar = self ._get_calendar (calendar_name )
694783
695784 todo_uid = str (uuid .uuid4 ())
@@ -715,6 +804,7 @@ async def update_todo(
715804 etag : str = "" ,
716805 ) -> dict [str , Any ]:
717806 """Update an existing todo/task."""
807+ await self ._ensure_calendar_home ()
718808 calendar = self ._get_calendar (calendar_name )
719809
720810 try :
@@ -754,6 +844,7 @@ async def update_todo(
754844
755845 async def delete_todo (self , calendar_name : str , todo_uid : str ) -> dict [str , Any ]:
756846 """Delete a todo/task."""
847+ await self ._ensure_calendar_home ()
757848 calendar = self ._get_calendar (calendar_name )
758849
759850 try :
@@ -771,6 +862,7 @@ async def search_todos_across_calendars(
771862 self , filters : dict [str , Any ] | None = None
772863 ) -> list [dict [str , Any ]]:
773864 """Search todos across all calendars."""
865+ await self ._ensure_calendar_home ()
774866 try :
775867 calendars = await self .list_calendars ()
776868 all_todos = []
@@ -1457,6 +1549,7 @@ async def bulk_update_events(
14571549 self , filter_criteria : dict [str , Any ], update_data : dict [str , Any ]
14581550 ) -> dict [str , Any ]:
14591551 """Bulk update events matching filter criteria."""
1552+ await self ._ensure_calendar_home ()
14601553 try :
14611554 start_datetime = None
14621555 end_datetime = None
0 commit comments