diff --git a/khal/icalendar.py b/khal/icalendar.py index 1e903af93..e702dcdff 100644 --- a/khal/icalendar.py +++ b/khal/icalendar.py @@ -524,6 +524,46 @@ def delete_instance(vevent: icalendar.Event, instance: dt.datetime) -> None: vevent.add("RDATE", rdates) +def delete_future(vevent: icalendar.Event, instance: dt.datetime) -> None: + """remove this and future recurrences of the instance by adding an UNTIL key + + does nothing if this is the first instance""" + + sanitized = sanitize_rrule(vevent) + rrule_param = sanitized.get("RRULE") + if rrule_param is None: + return + + dtstart_prop = sanitized["DTSTART"] + allday = dtstart_prop.params.get("VALUE") == "DATE" or not isinstance( + dtstart_prop.dt, dt.datetime + ) + # Don't use timezone for all-day events + dtstart = dtstart_prop.dt + events_tz = getattr(dtstart, "tzinfo", None) if not allday else None + + if events_tz: + dtstart = dtstart.replace(tzinfo=None) + # For all-day events, ensure dtstart is a date, not datetime. + if allday and isinstance(dtstart, dt.datetime): + dtstart = dtstart.date() + + rrule = dateutil.rrule.rrulestr( + rrule_param.to_ical().decode(), + dtstart=dtstart, + ignoretz=True, + ) + + if events_tz and instance.tzinfo: + instance = instance.astimezone(events_tz).replace(tzinfo=None) + + until = rrule.before(instance) + if until is None: + return + + vevent["RRULE"]["UNTIL"] = until + + def sort_key(vevent: icalendar.Event) -> tuple[str, float]: """helper function to determine order of VEVENTS so that recurrence-id events come after the corresponding rrule event, etc diff --git a/khal/khalendar/event.py b/khal/khalendar/event.py index 8fda097e5..a429ce28a 100644 --- a/khal/khalendar/event.py +++ b/khal/khalendar/event.py @@ -37,7 +37,7 @@ from khal.custom_types import LocaleConfiguration from khal.exceptions import FatalError -from khal.icalendar import cal_from_ics, delete_instance, invalid_timezone +from khal.icalendar import cal_from_ics, delete_future, delete_instance, invalid_timezone from khal.parse_datetime import timedelta2str from khal.plugins import FORMATTERS from khal.utils import generate_random_uid, is_aware, to_naive_utc, to_unix_time @@ -835,6 +835,18 @@ def delete_instance(self, instance: dt.datetime) -> None: for key in to_pop: self._vevents.pop(key) + def delete_future(self, instance: dt.datetime) -> None: + """delete future instances from this event + (instances only after `instance` will be deleted) + + we don't check, if that instance is an instance of the recurrence rules + defined in the event + """ + assert self.recurring + delete_future(self._vevents["PROTO"], instance) + # TODO check if we also have to delete RECURRENCE-ID + # TODO consider returning the relevant event + @property def status(self) -> str: return self._vevents[self.ref].get("STATUS", "") diff --git a/khal/khalendar/khalendar.py b/khal/khalendar/khalendar.py index 22aed97b6..00e5d6409 100644 --- a/khal/khalendar/khalendar.py +++ b/khal/khalendar/khalendar.py @@ -261,6 +261,33 @@ def delete_instance( self.update(event) return event + def delete_future( + self, + href: str, + etag: str | None, + calendar: str, + rec_id: dt.datetime, + ) -> Event | None: + """Delete future recurrences of an event specified by `href` from `calendar` + + returns the updated event, or None if it got geleted entirely""" + if self._calendars[calendar]["readonly"]: + raise ReadOnlyCalendarError() + event = self.get_event(href, calendar) + if etag and etag != event.etag: + raise EtagMissmatch() + + logger.warning(f"Event: {event._vevents}, {rec_id}") + + # TODO investigate whether DTSTART can be unset + if event._vevents["PROTO"]["DTSTART"] == rec_id: + self.delete(href, etag, calendar) + return None + + event.delete_future(rec_id) + self.update(event) + return event + def get_event(self, href: str, calendar: str) -> Event: """get an event by its href from the datatbase""" event_str, etag = self._backend.get_with_etag(href, calendar) diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py index 39594dcd1..917e4a025 100644 --- a/khal/ui/__init__.py +++ b/khal/ui/__init__.py @@ -81,6 +81,7 @@ class DeletionType(IntEnum): ALL = 0 INSTANCES = 1 + FUTURE = 2 class DateConversionError(Exception): @@ -203,6 +204,7 @@ def set_title(self, mark: str = " ") -> None: mark = { DeletionType.ALL: "D", DeletionType.INSTANCES: "d", + DeletionType.FUTURE: "d", # TODO consider using something different None: "", }[self.delete_status(self.recuid)] if self.relative: @@ -410,9 +412,7 @@ def ensure_date(self, day: dt.date) -> None: def days_to_next_already_loaded(self, day: dt.date) -> int: """return number of days until `day` is already loaded into the CalendarWidget""" - if len(self) == 0: - return 0 - elif self[0].date <= day <= self[-1].date: + if len(self) == 0 or self[0].date <= day <= self[-1].date: return 0 elif day <= self[0].date: return (self[0].date - day).days @@ -661,6 +661,7 @@ def __init__(self, elistbox, pane) -> None: self.event_width = int(self.pane._conf["view"]["event_view_weighting"]) self.delete_status = pane.delete_status self.toggle_delete_all = pane.toggle_delete_all + self.toggle_delete_future = pane.toggle_delete_future self.toggle_delete_instance = pane.toggle_delete_instance self.dlistbox: DListBox = elistbox self.container = urwid.Pile([self.dlistbox]) @@ -853,6 +854,13 @@ def delete_all(_): event.event.start_local, event.event.end_local, event.event.recurring ) + def delete_future(_): + self.toggle_delete_future(event.recuid) + self.pane.window.backtrack() + self.refresh_titles( + event.event.start_local, event.event.end_local, event.event.recurring + ) + if event.event.readonly: self.pane.window.alert( ("alert", f"Calendar {event.event.calendar} is read-only."), @@ -871,6 +879,7 @@ def delete_all(_): text="This is a recurring event.\nWhich instances do you want to delete?", buttons=[ ("Only this", delete_this), + ("This and future events", delete_future), ("All (past and future)", delete_all), ("Abort", self.pane.window.backtrack), ], @@ -891,7 +900,7 @@ def duplicate(self) -> None: # which are also copied. If the events' summary is edited it will show # up on disk but not be displayed in khal if self.focus_event is None: - return None + return event = self.focus_event.event.duplicate() try: self.pane.collection.insert(event) @@ -1095,7 +1104,11 @@ def __init__(self, collection, conf=None, title: str = "", description: str = "" self.window = None self._conf = conf self.collection = collection - self._deleted: dict[int, list[str]] = {DeletionType.ALL: [], DeletionType.INSTANCES: []} + self._deleted: dict[int, list[str]] = { + DeletionType.ALL: [], + DeletionType.INSTANCES: [], + DeletionType.FUTURE: [], + } ContainerWidget = linebox[self._conf["view"]["frame"]] if self._conf["view"]["dynamic_days"]: @@ -1159,6 +1172,8 @@ def delete_status(self, uid: str) -> DeletionType | None: return DeletionType.ALL elif uid in self._deleted[DeletionType.INSTANCES]: return DeletionType.INSTANCES + elif any(uid[0] == i[0] and uid[1] >= i[1] for i in self._deleted[DeletionType.FUTURE]): + return DeletionType.FUTURE else: return None @@ -1175,6 +1190,12 @@ def toggle_delete_instance(self, uid: str) -> None: else: self._deleted[DeletionType.INSTANCES].append(uid) + def toggle_delete_future(self, uid: str) -> None: + if uid in self._deleted[DeletionType.FUTURE]: + self._deleted[DeletionType.FUTURE].remove(uid) + else: + self._deleted[DeletionType.FUTURE].append(uid) + def cleanup(self, data): """delete all events marked for deletion""" # If we delete several recuids from the same vevent, the etag will @@ -1183,15 +1204,25 @@ def cleanup(self, data): # We therefore keep track of the etags of the events we already # deleted. updated_etags = {} + for part in self._deleted[DeletionType.ALL]: account, href, etag = part.split("\n", 2) self.collection.delete(href, etag, account) + for part, rec_id in self._deleted[DeletionType.INSTANCES]: account, href, etag = part.split("\n", 2) etag = updated_etags.get(href) or etag event = self.collection.delete_instance(href, etag, account, rec_id) updated_etags[event.href] = event.etag + # TODO check if additional conflict resolution is required (to avoid deleting twice) + for part, rec_id in self._deleted[DeletionType.FUTURE]: + account, href, etag = part.split("\n", 2) + etag = updated_etags.get(href) or etag + event = self.collection.delete_future(href, etag, account, rec_id) + if event is not None: + updated_etags[event.href] = event.etag + def keypress(self, size: tuple[int], key: str | None) -> str | None: binds = self._conf["keybindings"] if key in binds["search"]: diff --git a/log.log b/log.log new file mode 100644 index 000000000..e69de29bb