calendar: add EXDATE support for cancelling single occurrences of recurring events
event delete on a recurring event previously deleted the entire .ics file, killing the whole series. Now requires --date (adds EXDATE) or --all (deletes series) for recurring events, refusing to act without either flag.
This commit is contained in:
@@ -729,7 +729,14 @@ def cmd_event_list(args):
|
||||
|
||||
|
||||
def cmd_event_delete(args):
|
||||
"""Delete a calendar event by UID or summary match."""
|
||||
"""Delete a calendar event or cancel a single occurrence of a recurring event.
|
||||
|
||||
For recurring events:
|
||||
--date YYYY-MM-DD Cancel one occurrence by adding EXDATE (keeps the series)
|
||||
--all Delete the entire series (required safety flag)
|
||||
|
||||
Without --date or --all on a recurring event, the command refuses to act.
|
||||
"""
|
||||
if not args.uid and not args.match:
|
||||
print("Error: --uid or --match is required", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -755,9 +762,9 @@ def cmd_event_delete(args):
|
||||
uid = str(component.get("uid", ""))
|
||||
summary = str(component.get("summary", ""))
|
||||
if args.uid and args.uid in uid:
|
||||
matches.append((ics_path, uid, summary))
|
||||
matches.append((ics_path, uid, summary, component))
|
||||
elif args.match and args.match in summary:
|
||||
matches.append((ics_path, uid, summary))
|
||||
matches.append((ics_path, uid, summary, component))
|
||||
|
||||
if not matches:
|
||||
target = args.uid or args.match
|
||||
@@ -765,16 +772,72 @@ def cmd_event_delete(args):
|
||||
sys.exit(1)
|
||||
if len(matches) > 1:
|
||||
print(f"Error: Multiple events match:", file=sys.stderr)
|
||||
for _, uid, summary in matches:
|
||||
for _, uid, summary, _ in matches:
|
||||
print(f" - {summary} (uid: {uid})", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
ics_path, uid, summary = matches[0]
|
||||
ics_path.unlink()
|
||||
print(f"Deleted event: {summary} (uid: {uid})")
|
||||
ics_path, uid, summary, vevent = matches[0]
|
||||
has_rrule = vevent.get("rrule") is not None
|
||||
|
||||
if has_rrule and not args.date and not args.all:
|
||||
print(
|
||||
f"Error: '{summary}' is a recurring event. Use one of:\n"
|
||||
f" --date YYYY-MM-DD Cancel a single occurrence\n"
|
||||
f" --all Delete the entire series",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if args.date and has_rrule:
|
||||
# Add EXDATE to cancel a single occurrence
|
||||
_add_exdate(ics_path, vevent, args.date, summary, uid)
|
||||
else:
|
||||
# Delete the entire event (single event, or recurring with --all)
|
||||
ics_path.unlink()
|
||||
if has_rrule:
|
||||
print(f"Deleted recurring event series: {summary} (uid: {uid})")
|
||||
else:
|
||||
print(f"Deleted event: {summary} (uid: {uid})")
|
||||
|
||||
_sync_calendar()
|
||||
|
||||
|
||||
def _add_exdate(ics_path, vevent, date_str, summary, uid):
|
||||
"""Add an EXDATE to a recurring event to cancel a single occurrence."""
|
||||
exclude_date = _parse_date(date_str)
|
||||
|
||||
# Verify the date is a valid occurrence (matches the RRULE pattern)
|
||||
dtstart = vevent.get("dtstart").dt
|
||||
rrule = vevent.get("rrule")
|
||||
|
||||
# Check if dtstart is a datetime or date
|
||||
if isinstance(dtstart, datetime):
|
||||
start_date = dtstart.date() if hasattr(dtstart, 'date') else dtstart
|
||||
else:
|
||||
start_date = dtstart
|
||||
|
||||
if exclude_date < start_date:
|
||||
print(f"Error: --date {date_str} is before the event start ({start_date})", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Re-read and modify the ICS file to add EXDATE
|
||||
cal = Calendar.from_ical(ics_path.read_bytes())
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT" and str(component.get("uid", "")) == uid:
|
||||
# EXDATE value type must match DTSTART value type
|
||||
if isinstance(dtstart, datetime):
|
||||
# Use a datetime with the same time as DTSTART
|
||||
exclude_dt = datetime.combine(exclude_date, dtstart.time())
|
||||
component.add("exdate", [exclude_dt], parameters={"TZID": vevent.get("dtstart").params.get("TZID", DEFAULT_TIMEZONE)})
|
||||
else:
|
||||
component.add("exdate", [exclude_date])
|
||||
break
|
||||
|
||||
ics_path.write_bytes(cal.to_ical())
|
||||
print(f"Cancelled {summary} on {date_str} (added EXDATE, series continues)")
|
||||
print(f"Updated: {ics_path}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -823,9 +886,11 @@ def main():
|
||||
elist_p.add_argument("--format", default="", help="khal format string (e.g. '{uid} {title}')")
|
||||
|
||||
# event delete
|
||||
edel_p = event_sub.add_parser("delete", help="Delete an event")
|
||||
edel_p = event_sub.add_parser("delete", help="Delete an event or cancel one occurrence")
|
||||
edel_p.add_argument("--uid", default="", help="Event UID")
|
||||
edel_p.add_argument("--match", default="", help="Match on summary text")
|
||||
edel_p.add_argument("--date", default="", help="Cancel single occurrence on this date (YYYY-MM-DD, for recurring events)")
|
||||
edel_p.add_argument("--all", action="store_true", help="Delete entire recurring series (safety flag)")
|
||||
|
||||
# --- todo ---
|
||||
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")
|
||||
|
||||
Reference in New Issue
Block a user