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:
Yanxin Lu
2026-03-26 09:19:02 -07:00
parent d2bc01f16c
commit e1f1c0f334
4 changed files with 147 additions and 16 deletions

View File

@@ -159,18 +159,24 @@ $SKILL_DIR/scripts/calendar.sh event list --format "{uid} {title}"
# Custom date range
$SKILL_DIR/scripts/calendar.sh event list --range-start "2026-04-01" --range-end "2026-04-30"
# Delete by UID
$SKILL_DIR/scripts/calendar.sh event delete --uid "abc123@openclaw"
# Delete a single (non-recurring) event
$SKILL_DIR/scripts/calendar.sh event delete --match "Lunch at Tartine"
# Delete by summary match
$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot (Tue)"
# Cancel ONE occurrence of a recurring event (adds EXDATE, keeps the series)
$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot" --date "2026-03-28"
# Delete an entire recurring series (requires --all safety flag)
$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot" --all
```
### Event Delete Safety
- Deletes only ONE event at a time. If multiple events match, it lists them and exits.
- **Recurring events require `--date` or `--all`**. Without either flag, the tool refuses to act and shows usage.
- `--date YYYY-MM-DD`: Adds an EXDATE to skip that one occurrence. The rest of the series continues.
- `--all`: Deletes the entire .ics file (the whole series). Use only when the user explicitly wants to cancel all future occurrences.
- **NEVER use `rm` on calendar .ics files directly.** Always use `event delete`.
- After deleting, verify with `event list` or `khal list`.
- After deleting/cancelling, verify with `event list` or `khal list`.
---

View File

@@ -382,7 +382,60 @@ $SKILL_DIR/scripts/calendar.sh event list --search "Delete Me"
- [ ] Other events are untouched
- [ ] `vdirsyncer sync` ran after delete
## 18. Regression: Existing Invite Commands
## 18. Event Delete: Cancel Single Occurrence (EXDATE)
Test that `--date` cancels one occurrence of a recurring event without deleting the series.
```bash
# Create a recurring event (weekly on Saturday, 4 weeks)
NEXT_SAT=$(python3 -c "from datetime import date,timedelta; d=date.today(); d+=timedelta((5-d.weekday())%7 or 7); print(d)")
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--subject "EXDATE Test (Sat)" \
--summary "EXDATE Test (Sat)" \
--start "${NEXT_SAT}T10:00:00" \
--end "${NEXT_SAT}T11:00:00" \
--rrule "FREQ=WEEKLY;COUNT=4;BYDAY=SA"
# Verify it exists
$SKILL_DIR/scripts/calendar.sh event list --search "EXDATE Test"
# Cancel just the first occurrence
$SKILL_DIR/scripts/calendar.sh event delete --match "EXDATE Test" --date "$NEXT_SAT"
# Verify: .ics file still exists (not deleted)
ls ~/.openclaw/workspace/calendars/home/ | grep -i exdate
```
**Verify:**
- [ ] `event delete --match ... --date ...` prints "Cancelled ... (added EXDATE, series continues)"
- [ ] `.ics` file still exists in calendar dir
- [ ] `khal list` no longer shows the cancelled date but shows subsequent Saturdays
## 19. Event Delete: Recurring Without --date or --all (Safety Guard)
```bash
# Try to delete the recurring event without --date or --all — should FAIL
$SKILL_DIR/scripts/calendar.sh event delete --match "EXDATE Test"
```
**Verify:**
- [ ] Script exits with error
- [ ] Error message explains the two options: `--date` or `--all`
## 20. Event Delete: Recurring With --all
```bash
# Delete the entire series
$SKILL_DIR/scripts/calendar.sh event delete --match "EXDATE Test" --all
```
**Verify:**
- [ ] .ics file is removed
- [ ] `event list --search "EXDATE Test"` shows nothing
## 21. Regression: Existing Invite Commands (was #18)
Verify new features didn't break VEVENT flow.
@@ -441,3 +494,4 @@ todo list
| Recurring events on wrong day | DTSTART was not aligned with BYDAY. Delete the event and resend with correct `--start` |
| SMTP rate limit / EOF error | Too many sends too fast. Wait 10+ seconds between sends (Migadu limit) |
| Events disappeared after cleanup | **Never use `rm *.ics`** on calendar dirs. Use `event delete --match` instead |
| Recurring series deleted when cancelling one date | Use `--date YYYY-MM-DD` to add EXDATE, not bare `event delete` (which requires `--all` for recurring) |

View File

@@ -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")