fix(caldav): point-event round-trip, recurrence push, delete propagation
Drift-audit Group 5 #7/#8 + Group 7 (CalDAV write-path): - Point events no longer fabricate a 60-min DTEND: caldav.create_event emits DTSTART-only when there's no end and no duration, so the next pull doesn't read it back as duration_minutes=60 and silently lengthen the event. - Recurrence edits now propagate: caldav.update_event gains a recurrence param (sentinel = leave unchanged; value/empty = set/clear RRULE), and _push_update passes the local event's rule so a changed/cleared RRULE isn't overwritten by the stale remote rule on the next pull. - Event deletions propagate to CalDAV: trash.delete captures an event's caldav_uid before soft-deleting and fires _push_delete, so a UI/MCP delete removes the remote copy instead of leaving it to linger. (delete_event the service primitive is kept — still tested/usable — rather than removed.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name", "caldav_timezone"]
|
||||
|
||||
# Sentinel: distinguishes "leave the RRULE untouched" from "clear it" (None/"")
|
||||
# in update_event, since None is a meaningful value for recurrence.
|
||||
_RECURRENCE_UNSET = object()
|
||||
|
||||
|
||||
async def get_caldav_config(user_id: int) -> dict[str, str]:
|
||||
"""Return the user's CalDAV config from their settings."""
|
||||
@@ -214,14 +218,22 @@ async def create_event(
|
||||
result_end = d_end.isoformat()
|
||||
else:
|
||||
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
|
||||
event.add("dtstart", dt_start)
|
||||
result_start = dt_start.isoformat()
|
||||
if end:
|
||||
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
|
||||
elif duration:
|
||||
dt_end = dt_start + timedelta(minutes=duration)
|
||||
else:
|
||||
dt_end = dt_start + timedelta(minutes=duration or 60)
|
||||
event.add("dtstart", dt_start)
|
||||
event.add("dtend", dt_end)
|
||||
result_start = dt_start.isoformat()
|
||||
result_end = dt_end.isoformat()
|
||||
dt_end = None
|
||||
if dt_end is not None:
|
||||
event.add("dtend", dt_end)
|
||||
result_end = dt_end.isoformat()
|
||||
else:
|
||||
# Point event (no end, no duration): emit DTSTART only. Fabricating
|
||||
# a 60-min DTEND here would round-trip back on the next pull as
|
||||
# duration_minutes=60, silently lengthening a point event.
|
||||
result_end = None
|
||||
|
||||
if description:
|
||||
event.add("description", description)
|
||||
@@ -325,8 +337,14 @@ async def update_event(
|
||||
location: str | None = None,
|
||||
timezone: str | None = None,
|
||||
calendar_name: str | None = None,
|
||||
recurrence: str | None | object = _RECURRENCE_UNSET,
|
||||
) -> dict:
|
||||
"""Update a calendar event matching the query."""
|
||||
"""Update a calendar event matching the query.
|
||||
|
||||
``recurrence``: leave at the sentinel to keep the existing RRULE; pass an
|
||||
RRULE string to set it, or None/"" to remove it. The push path passes the
|
||||
local event's recurrence so RRULE edits propagate to the server.
|
||||
"""
|
||||
config = await get_caldav_config(user_id)
|
||||
_check_config(config)
|
||||
tz = timezone or config.get("caldav_timezone") or None
|
||||
@@ -382,6 +400,18 @@ async def update_event(
|
||||
if "LOCATION" in component:
|
||||
del component["LOCATION"]
|
||||
component.add("location", location)
|
||||
if recurrence is not _RECURRENCE_UNSET:
|
||||
# Authoritatively sync the RRULE to the local event: drop the old
|
||||
# rule, then re-add if a non-empty rule was provided (else clear it).
|
||||
if "RRULE" in component:
|
||||
del component["RRULE"]
|
||||
if recurrence:
|
||||
rrule_parts = {}
|
||||
for part in str(recurrence).split(";"):
|
||||
if "=" in part:
|
||||
key, value = part.split("=", 1)
|
||||
rrule_parts[key.strip().lower()] = value.strip()
|
||||
component.add("rrule", rrule_parts)
|
||||
|
||||
# Rebuild ical data and save
|
||||
cal_data = icalendar.Calendar()
|
||||
|
||||
Reference in New Issue
Block a user