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"]
|
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]:
|
async def get_caldav_config(user_id: int) -> dict[str, str]:
|
||||||
"""Return the user's CalDAV config from their settings."""
|
"""Return the user's CalDAV config from their settings."""
|
||||||
@@ -214,14 +218,22 @@ async def create_event(
|
|||||||
result_end = d_end.isoformat()
|
result_end = d_end.isoformat()
|
||||||
else:
|
else:
|
||||||
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
|
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
|
||||||
|
event.add("dtstart", dt_start)
|
||||||
|
result_start = dt_start.isoformat()
|
||||||
if end:
|
if end:
|
||||||
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
|
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
|
||||||
|
elif duration:
|
||||||
|
dt_end = dt_start + timedelta(minutes=duration)
|
||||||
else:
|
else:
|
||||||
dt_end = dt_start + timedelta(minutes=duration or 60)
|
dt_end = None
|
||||||
event.add("dtstart", dt_start)
|
if dt_end is not None:
|
||||||
event.add("dtend", dt_end)
|
event.add("dtend", dt_end)
|
||||||
result_start = dt_start.isoformat()
|
result_end = dt_end.isoformat()
|
||||||
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:
|
if description:
|
||||||
event.add("description", description)
|
event.add("description", description)
|
||||||
@@ -325,8 +337,14 @@ async def update_event(
|
|||||||
location: str | None = None,
|
location: str | None = None,
|
||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
calendar_name: str | None = None,
|
calendar_name: str | None = None,
|
||||||
|
recurrence: str | None | object = _RECURRENCE_UNSET,
|
||||||
) -> dict:
|
) -> 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)
|
config = await get_caldav_config(user_id)
|
||||||
_check_config(config)
|
_check_config(config)
|
||||||
tz = timezone or config.get("caldav_timezone") or None
|
tz = timezone or config.get("caldav_timezone") or None
|
||||||
@@ -382,6 +400,18 @@ async def update_event(
|
|||||||
if "LOCATION" in component:
|
if "LOCATION" in component:
|
||||||
del component["LOCATION"]
|
del component["LOCATION"]
|
||||||
component.add("location", 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
|
# Rebuild ical data and save
|
||||||
cal_data = icalendar.Calendar()
|
cal_data = icalendar.Calendar()
|
||||||
|
|||||||
@@ -450,6 +450,9 @@ async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
|
|||||||
end=derived_end.isoformat() if derived_end else None,
|
end=derived_end.isoformat() if derived_end else None,
|
||||||
description=event.description or None,
|
description=event.description or None,
|
||||||
location=event.location or None,
|
location=event.location or None,
|
||||||
|
# Propagate the (possibly cleared) RRULE so a local recurrence edit
|
||||||
|
# isn't overwritten by the stale remote rule on the next pull.
|
||||||
|
recurrence=event.recurrence,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True)
|
logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True)
|
||||||
|
|||||||
@@ -149,11 +149,28 @@ async def delete(user_id: int, entity_type: str, entity_id: int) -> str | None:
|
|||||||
"""
|
"""
|
||||||
batch = str(uuid.uuid4())
|
batch = str(uuid.uuid4())
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
caldav_event: tuple[str, str] | None = None
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
if not await _exists_alive(session, user_id, entity_type, entity_id):
|
if not await _exists_alive(session, user_id, entity_type, entity_id):
|
||||||
return None
|
return None
|
||||||
|
# Capture CalDAV linkage before soft-deleting so we can propagate the
|
||||||
|
# deletion to the external server (the row stays present locally).
|
||||||
|
if entity_type == "event":
|
||||||
|
row = (await session.execute(
|
||||||
|
select(Event.caldav_uid, Event.title).where(
|
||||||
|
Event.id == entity_id, Event.user_id == user_id
|
||||||
|
)
|
||||||
|
)).first()
|
||||||
|
if row and row[0]:
|
||||||
|
caldav_event = (row[0], row[1])
|
||||||
await _cascade(session, user_id, entity_type, entity_id, batch, now)
|
await _cascade(session, user_id, entity_type, entity_id, batch, now)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
# Without this the soft-delete only hides the event locally and the remote
|
||||||
|
# copy lingers forever (and re-appears on any client syncing that server).
|
||||||
|
if caldav_event:
|
||||||
|
import asyncio
|
||||||
|
from fabledassistant.services.events import _push_delete
|
||||||
|
asyncio.create_task(_push_delete(caldav_event[0], caldav_event[1], user_id))
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user