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:
10
TOOLS.md
10
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
|
||||||
$SKILL_DIR/scripts/calendar.sh event list --search "Allergy"
|
$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
|
$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)
|
$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(待办管理)
|
**依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)、todoman(待办管理)
|
||||||
**同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步
|
**同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步
|
||||||
**注意**: 发送日历邀请属于对外邮件,需先确认
|
**注意**: 发送日历邀请属于对外邮件,需先确认
|
||||||
@@ -150,6 +155,7 @@ $SKILL_DIR/scripts/calendar.sh todo check # 每日摘要(cron)
|
|||||||
**安全规则**:
|
**安全规则**:
|
||||||
- 周期性邀请务必先 `--dry-run` 验证 ICS 内容
|
- 周期性邀请务必先 `--dry-run` 验证 ICS 内容
|
||||||
- **绝不用 `rm` 删日历 .ics 文件**,只用 `event delete`
|
- **绝不用 `rm` 删日历 .ics 文件**,只用 `event delete`
|
||||||
|
- **取消周期性事件的单次用 `--date`**,不要用 `--all`(会删掉整个系列)
|
||||||
- 连续发多封邮件时,每封间隔 10 秒以上(Migadu SMTP 限频)
|
- 连续发多封邮件时,每封间隔 10 秒以上(Migadu SMTP 限频)
|
||||||
|
|
||||||
### OpenClaw Cron 定时任务
|
### OpenClaw Cron 定时任务
|
||||||
|
|||||||
@@ -159,18 +159,24 @@ $SKILL_DIR/scripts/calendar.sh event list --format "{uid} {title}"
|
|||||||
# Custom date range
|
# Custom date range
|
||||||
$SKILL_DIR/scripts/calendar.sh event list --range-start "2026-04-01" --range-end "2026-04-30"
|
$SKILL_DIR/scripts/calendar.sh event list --range-start "2026-04-01" --range-end "2026-04-30"
|
||||||
|
|
||||||
# Delete by UID
|
# Delete a single (non-recurring) event
|
||||||
$SKILL_DIR/scripts/calendar.sh event delete --uid "abc123@openclaw"
|
$SKILL_DIR/scripts/calendar.sh event delete --match "Lunch at Tartine"
|
||||||
|
|
||||||
# Delete by summary match
|
# Cancel ONE occurrence of a recurring event (adds EXDATE, keeps the series)
|
||||||
$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot (Tue)"
|
$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
|
### Event Delete Safety
|
||||||
|
|
||||||
- Deletes only ONE event at a time. If multiple events match, it lists them and exits.
|
- 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`.
|
- **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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -382,7 +382,60 @@ $SKILL_DIR/scripts/calendar.sh event list --search "Delete Me"
|
|||||||
- [ ] Other events are untouched
|
- [ ] Other events are untouched
|
||||||
- [ ] `vdirsyncer sync` ran after delete
|
- [ ] `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.
|
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` |
|
| 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) |
|
| 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 |
|
| 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) |
|
||||||
|
|||||||
@@ -729,7 +729,14 @@ def cmd_event_list(args):
|
|||||||
|
|
||||||
|
|
||||||
def cmd_event_delete(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:
|
if not args.uid and not args.match:
|
||||||
print("Error: --uid or --match is required", file=sys.stderr)
|
print("Error: --uid or --match is required", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -755,9 +762,9 @@ def cmd_event_delete(args):
|
|||||||
uid = str(component.get("uid", ""))
|
uid = str(component.get("uid", ""))
|
||||||
summary = str(component.get("summary", ""))
|
summary = str(component.get("summary", ""))
|
||||||
if args.uid and args.uid in uid:
|
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:
|
elif args.match and args.match in summary:
|
||||||
matches.append((ics_path, uid, summary))
|
matches.append((ics_path, uid, summary, component))
|
||||||
|
|
||||||
if not matches:
|
if not matches:
|
||||||
target = args.uid or args.match
|
target = args.uid or args.match
|
||||||
@@ -765,16 +772,72 @@ def cmd_event_delete(args):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if len(matches) > 1:
|
if len(matches) > 1:
|
||||||
print(f"Error: Multiple events match:", file=sys.stderr)
|
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)
|
print(f" - {summary} (uid: {uid})", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
ics_path, uid, summary = matches[0]
|
ics_path, uid, summary, vevent = matches[0]
|
||||||
ics_path.unlink()
|
has_rrule = vevent.get("rrule") is not None
|
||||||
print(f"Deleted event: {summary} (uid: {uid})")
|
|
||||||
|
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()
|
_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
|
# CLI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -823,9 +886,11 @@ def main():
|
|||||||
elist_p.add_argument("--format", default="", help="khal format string (e.g. '{uid} {title}')")
|
elist_p.add_argument("--format", default="", help="khal format string (e.g. '{uid} {title}')")
|
||||||
|
|
||||||
# event delete
|
# 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("--uid", default="", help="Event UID")
|
||||||
edel_p.add_argument("--match", default="", help="Match on summary text")
|
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 ---
|
||||||
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")
|
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")
|
||||||
|
|||||||
Reference in New Issue
Block a user