From e1f1c0f3348af20ac89ba215b4e1474c6f260b64 Mon Sep 17 00:00:00 2001 From: Yanxin Lu Date: Thu, 26 Mar 2026 09:19:02 -0700 Subject: [PATCH] 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. --- TOOLS.md | 10 +++- skills/calendar/SKILL.md | 16 ++++-- skills/calendar/TESTING.md | 56 +++++++++++++++++++- skills/calendar/scripts/cal_tool.py | 81 ++++++++++++++++++++++++++--- 4 files changed, 147 insertions(+), 16 deletions(-) diff --git a/TOOLS.md b/TOOLS.md index 2746aa3..37a40d1 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -131,7 +131,12 @@ $SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action accept # 事件管理(查看、搜索、删除) $SKILL_DIR/scripts/calendar.sh event list $SKILL_DIR/scripts/calendar.sh event list --search "Allergy" -$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot (Tue)" +# 取消周期性事件的单次(加 EXDATE,系列保留) +$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot" --date "2026-03-28" +# 删除整个周期性系列(需要 --all 安全标志) +$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot" --all +# 删除非周期性事件 +$SKILL_DIR/scripts/calendar.sh event delete --match "Lunch" # 待办管理 $SKILL_DIR/scripts/calendar.sh todo add --summary "跟进报销" --due "2026-03-25" --priority high @@ -142,7 +147,7 @@ $SKILL_DIR/scripts/calendar.sh todo delete --match "报销" $SKILL_DIR/scripts/calendar.sh todo check # 每日摘要(cron) ``` -**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、事件管理 (`event list/delete`)、待办管理 (`todo add/list/edit/complete/delete/check`) +**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、事件管理 (`event list/delete --date/--all`)、待办管理 (`todo add/list/edit/complete/delete/check`) **依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)、todoman(待办管理) **同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步 **注意**: 发送日历邀请属于对外邮件,需先确认 @@ -150,6 +155,7 @@ $SKILL_DIR/scripts/calendar.sh todo check # 每日摘要(cron) **安全规则**: - 周期性邀请务必先 `--dry-run` 验证 ICS 内容 - **绝不用 `rm` 删日历 .ics 文件**,只用 `event delete` +- **取消周期性事件的单次用 `--date`**,不要用 `--all`(会删掉整个系列) - 连续发多封邮件时,每封间隔 10 秒以上(Migadu SMTP 限频) ### OpenClaw Cron 定时任务 diff --git a/skills/calendar/SKILL.md b/skills/calendar/SKILL.md index 41c783e..17b9683 100644 --- a/skills/calendar/SKILL.md +++ b/skills/calendar/SKILL.md @@ -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`. --- diff --git a/skills/calendar/TESTING.md b/skills/calendar/TESTING.md index 02053d5..bb28836 100644 --- a/skills/calendar/TESTING.md +++ b/skills/calendar/TESTING.md @@ -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) | diff --git a/skills/calendar/scripts/cal_tool.py b/skills/calendar/scripts/cal_tool.py index 9c6fea5..31cf8b1 100644 --- a/skills/calendar/scripts/cal_tool.py +++ b/skills/calendar/scripts/cal_tool.py @@ -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")