diff --git a/src/fabledassistant/services/caldav.py b/src/fabledassistant/services/caldav.py index 12a0ec7..cb4c9a3 100644 --- a/src/fabledassistant/services/caldav.py +++ b/src/fabledassistant/services/caldav.py @@ -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() diff --git a/src/fabledassistant/services/events.py b/src/fabledassistant/services/events.py index ddd94c6..7bbe17d 100644 --- a/src/fabledassistant/services/events.py +++ b/src/fabledassistant/services/events.py @@ -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, description=event.description 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: logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True) diff --git a/src/fabledassistant/services/trash.py b/src/fabledassistant/services/trash.py index b0d068a..07da97c 100644 --- a/src/fabledassistant/services/trash.py +++ b/src/fabledassistant/services/trash.py @@ -149,11 +149,28 @@ async def delete(user_id: int, entity_type: str, entity_id: int) -> str | None: """ batch = str(uuid.uuid4()) now = datetime.now(timezone.utc) + caldav_event: tuple[str, str] | None = None async with async_session() as session: if not await _exists_alive(session, user_id, entity_type, entity_id): 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 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