Compare commits

..

2 Commits

Author SHA1 Message Date
Yanxin Lu
7227574b62 Merge branch 'main' of ssh://git.luyanxin.com:8103/lyx/youlu-openclaw-workspace
merge
2026-03-26 09:19:23 -07:00
Yanxin Lu
e1f1c0f334 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.
2026-03-26 09:19:02 -07:00
4 changed files with 147 additions and 16 deletions

View File

@@ -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邮件、vdirsyncerCalDAV 同步、khal查看日历、todoman待办管理 **依赖**: himalaya邮件、vdirsyncerCalDAV 同步、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 定时任务

View File

@@ -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`.
--- ---

View File

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

View File

@@ -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]
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() ics_path.unlink()
if has_rrule:
print(f"Deleted recurring event series: {summary} (uid: {uid})")
else:
print(f"Deleted event: {summary} (uid: {uid})") 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")