Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions khal/icalendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion khal/khalendar/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", "")
Expand Down
27 changes: 27 additions & 0 deletions khal/khalendar/khalendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 36 additions & 5 deletions khal/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
class DeletionType(IntEnum):
ALL = 0
INSTANCES = 1
FUTURE = 2


class DateConversionError(Exception):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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."),
Expand All @@ -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),
],
Expand All @@ -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)
Expand Down Expand Up @@ -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"]:
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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"]:
Expand Down
Empty file added log.log
Empty file.