calendar: add recurring events, event management, and safety rules
Add --rrule flag to send with DTSTART/BYDAY validation (RFC 5545). Add event list (via khal) and event delete (safe single-event removal). Document safety rules from 2026-03-25 incident: always dry-run recurring events, never rm .ics files, space out SMTP sends.
This commit is contained in:
14
MEMORY.md
14
MEMORY.md
@@ -73,6 +73,7 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_
|
|||||||
### 3. 日历邀请 + CalDAV 同步
|
### 3. 日历邀请 + CalDAV 同步
|
||||||
**状态**: 运行中
|
**状态**: 运行中
|
||||||
**创建**: 2026-03-18
|
**创建**: 2026-03-18
|
||||||
|
**更新**: 2026-03-25(添加 RRULE 支持 + 事件管理 + 安全规则)
|
||||||
**配置**:
|
**配置**:
|
||||||
- 技能: `~/.openclaw/workspace/skills/calendar/`
|
- 技能: `~/.openclaw/workspace/skills/calendar/`
|
||||||
- 日历数据: `~/.openclaw/workspace/calendars/` (home/work/tasks/journals)
|
- 日历数据: `~/.openclaw/workspace/calendars/` (home/work/tasks/journals)
|
||||||
@@ -82,12 +83,19 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_
|
|||||||
- 运行方式: `uv run`(依赖 `icalendar` 库)
|
- 运行方式: `uv run`(依赖 `icalendar` 库)
|
||||||
|
|
||||||
**功能**:
|
**功能**:
|
||||||
- 发送日历邀请给外部收件人
|
- 发送日历邀请,支持周期性事件(`--rrule`)
|
||||||
- 接受/拒绝/暂定回复邀请(回复给 organizer)
|
- 接受/拒绝/暂定回复邀请(回复给 organizer)
|
||||||
- VTODO 待办管理(add/list/complete/delete/check)
|
- 事件管理(`event list` / `event delete`)
|
||||||
|
- VTODO 待办管理(add/list/edit/complete/delete/check)
|
||||||
- 发送/回复/待办操作后自动 `vdirsyncer sync` 同步到 CalDAV
|
- 发送/回复/待办操作后自动 `vdirsyncer sync` 同步到 CalDAV
|
||||||
- 心跳定期同步日历
|
- 心跳定期同步日历
|
||||||
|
|
||||||
|
**⚠️ 重要安全规则**(2026-03-25 事故教训):
|
||||||
|
- 周期性邀请**必须先 `--dry-run`** 验证 ICS 内容
|
||||||
|
- `--rrule` 的 `BYDAY` 必须和 `--start` 日期的星期匹配(工具会自动校验)
|
||||||
|
- **绝不用 `rm` 删日历 .ics 文件**,只用 `event delete --match`
|
||||||
|
- 连续发多封邮件时,每封间隔 10 秒以上(Migadu SMTP 限频)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📁 项目文件索引
|
## 📁 项目文件索引
|
||||||
@@ -100,4 +108,4 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_最后更新: 2026-03-24_
|
_最后更新: 2026-03-25_
|
||||||
|
|||||||
26
TOOLS.md
26
TOOLS.md
@@ -118,12 +118,20 @@ $SKILL_DIR/scripts/calendar.sh send \
|
|||||||
--subject "Lunch" --summary "Lunch at Tartine" \
|
--subject "Lunch" --summary "Lunch at Tartine" \
|
||||||
--start "2026-03-20T12:00:00" --end "2026-03-20T13:00:00"
|
--start "2026-03-20T12:00:00" --end "2026-03-20T13:00:00"
|
||||||
|
|
||||||
# 接受邀请(从邮件中提取 .ics)
|
# 发送周期性邀请(--start 必须落在 BYDAY 指定的那天!)
|
||||||
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
|
--to "alice@example.com" \
|
||||||
|
--subject "Weekly Shot" --summary "Allergy Shot (Tue)" \
|
||||||
|
--start "2026-03-31T14:30:00" --end "2026-03-31T15:00:00" \
|
||||||
|
--rrule "FREQ=WEEKLY;COUNT=13;BYDAY=TU"
|
||||||
|
|
||||||
|
# 接受/拒绝邀请
|
||||||
$SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action accept
|
$SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action accept
|
||||||
|
|
||||||
# 拒绝邀请(附带留言)
|
# 事件管理(查看、搜索、删除)
|
||||||
$SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action decline \
|
$SKILL_DIR/scripts/calendar.sh event list
|
||||||
--comment "Sorry, I have a conflict."
|
$SKILL_DIR/scripts/calendar.sh event list --search "Allergy"
|
||||||
|
$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot (Tue)"
|
||||||
|
|
||||||
# 待办管理
|
# 待办管理
|
||||||
$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
|
||||||
@@ -132,16 +140,18 @@ $SKILL_DIR/scripts/calendar.sh todo edit --match "报销" --due "2026-03-28"
|
|||||||
$SKILL_DIR/scripts/calendar.sh todo complete --match "报销"
|
$SKILL_DIR/scripts/calendar.sh todo complete --match "报销"
|
||||||
$SKILL_DIR/scripts/calendar.sh todo delete --match "报销"
|
$SKILL_DIR/scripts/calendar.sh todo delete --match "报销"
|
||||||
$SKILL_DIR/scripts/calendar.sh todo check # 每日摘要(cron)
|
$SKILL_DIR/scripts/calendar.sh todo check # 每日摘要(cron)
|
||||||
|
|
||||||
# 查看日历(检查冲突)
|
|
||||||
khal list today 7d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、待办管理 (`todo add/list/edit/complete/delete/check`)
|
**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、事件管理 (`event list/delete`)、待办管理 (`todo add/list/edit/complete/delete/check`)
|
||||||
**依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)、todoman(待办管理)
|
**依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)、todoman(待办管理)
|
||||||
**同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步
|
**同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步
|
||||||
**注意**: 发送日历邀请属于对外邮件,需先确认
|
**注意**: 发送日历邀请属于对外邮件,需先确认
|
||||||
|
|
||||||
|
**安全规则**:
|
||||||
|
- 周期性邀请务必先 `--dry-run` 验证 ICS 内容
|
||||||
|
- **绝不用 `rm` 删日历 .ics 文件**,只用 `event delete`
|
||||||
|
- 连续发多封邮件时,每封间隔 10 秒以上(Migadu SMTP 限频)
|
||||||
|
|
||||||
### OpenClaw Cron 定时任务
|
### OpenClaw Cron 定时任务
|
||||||
|
|
||||||
**规则**: 确定性 shell 任务用 `systemEvent`,需要 LLM 判断的用 `agentTurn`
|
**规则**: 确定性 shell 任务用 `systemEvent`,需要 LLM 判断的用 `agentTurn`
|
||||||
|
|||||||
@@ -33,12 +33,16 @@ All commands go through the wrapper script:
|
|||||||
```bash
|
```bash
|
||||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar
|
SKILL_DIR=~/.openclaw/workspace/skills/calendar
|
||||||
|
|
||||||
# Send an invite
|
# Send an invite (supports recurring events via --rrule)
|
||||||
$SKILL_DIR/scripts/calendar.sh send [options]
|
$SKILL_DIR/scripts/calendar.sh send [options]
|
||||||
|
|
||||||
# Reply to an invite
|
# Reply to an invite
|
||||||
$SKILL_DIR/scripts/calendar.sh reply [options]
|
$SKILL_DIR/scripts/calendar.sh reply [options]
|
||||||
|
|
||||||
|
# Manage events
|
||||||
|
$SKILL_DIR/scripts/calendar.sh event list [options]
|
||||||
|
$SKILL_DIR/scripts/calendar.sh event delete [options]
|
||||||
|
|
||||||
# Manage todos
|
# Manage todos
|
||||||
$SKILL_DIR/scripts/calendar.sh todo add [options]
|
$SKILL_DIR/scripts/calendar.sh todo add [options]
|
||||||
$SKILL_DIR/scripts/calendar.sh todo list [options]
|
$SKILL_DIR/scripts/calendar.sh todo list [options]
|
||||||
@@ -76,6 +80,7 @@ $SKILL_DIR/scripts/calendar.sh send \
|
|||||||
| `--location` | No | Event location |
|
| `--location` | No | Event location |
|
||||||
| `--description` | No | Event description / notes |
|
| `--description` | No | Event description / notes |
|
||||||
| `--organizer` | No | Organizer display name (defaults to `--from`) |
|
| `--organizer` | No | Organizer display name (defaults to `--from`) |
|
||||||
|
| `--rrule` | No | Recurrence rule (e.g. `FREQ=WEEKLY;COUNT=13;BYDAY=TU`) |
|
||||||
| `--uid` | No | Custom event UID (auto-generated if omitted) |
|
| `--uid` | No | Custom event UID (auto-generated if omitted) |
|
||||||
| `--account` | No | Himalaya account name (if not default) |
|
| `--account` | No | Himalaya account name (if not default) |
|
||||||
| `--dry-run` | No | Print ICS + MIME without sending |
|
| `--dry-run` | No | Print ICS + MIME without sending |
|
||||||
@@ -101,7 +106,17 @@ $SKILL_DIR/scripts/calendar.sh send \
|
|||||||
--location "Zoom - https://zoom.us/j/123456" \
|
--location "Zoom - https://zoom.us/j/123456" \
|
||||||
--description "Weekly check-in. Agenda: updates, blockers, action items."
|
--description "Weekly check-in. Agenda: updates, blockers, action items."
|
||||||
|
|
||||||
# Dry run
|
# Recurring: every Tuesday for 13 weeks (--start MUST fall on a Tuesday)
|
||||||
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
|
--to "alice@example.com" \
|
||||||
|
--subject "Allergy Shot (Tue)" \
|
||||||
|
--summary "Allergy Shot (Tue)" \
|
||||||
|
--start "2026-03-31T14:30:00" \
|
||||||
|
--end "2026-03-31T15:00:00" \
|
||||||
|
--location "11965 Venice Blvd. #300, LA" \
|
||||||
|
--rrule "FREQ=WEEKLY;COUNT=13;BYDAY=TU"
|
||||||
|
|
||||||
|
# Dry run (always use for recurring events to verify)
|
||||||
$SKILL_DIR/scripts/calendar.sh send \
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
--to "test@example.com" \
|
--to "test@example.com" \
|
||||||
--subject "Test" \
|
--subject "Test" \
|
||||||
@@ -111,6 +126,52 @@ $SKILL_DIR/scripts/calendar.sh send \
|
|||||||
--dry-run
|
--dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Recurring Events (--rrule)
|
||||||
|
|
||||||
|
The `--rrule` flag accepts an RFC 5545 RRULE string. Common patterns:
|
||||||
|
|
||||||
|
| Pattern | RRULE |
|
||||||
|
|---------|-------|
|
||||||
|
| Weekly on Tue, 13 weeks | `FREQ=WEEKLY;COUNT=13;BYDAY=TU` |
|
||||||
|
| Weekly on Mon/Wed/Fri, until date | `FREQ=WEEKLY;UNTIL=20260630T000000Z;BYDAY=MO,WE,FR` |
|
||||||
|
| Every 2 weeks on Thu | `FREQ=WEEKLY;INTERVAL=2;BYDAY=TH` |
|
||||||
|
| Monthly on the 15th, 6 times | `FREQ=MONTHLY;COUNT=6;BYMONTHDAY=15` |
|
||||||
|
| Daily for 5 days | `FREQ=DAILY;COUNT=5` |
|
||||||
|
|
||||||
|
**Critical rule**: For `FREQ=WEEKLY` with a single `BYDAY`, the `--start` date **must fall on that day of the week**. The tool validates this and will reject mismatches. RFC 5545 says mismatched DTSTART/BYDAY produces undefined behavior.
|
||||||
|
|
||||||
|
**Best practice**: Always `--dry-run` first for recurring events to verify the generated ICS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Managing Events
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List upcoming events (next 90 days)
|
||||||
|
$SKILL_DIR/scripts/calendar.sh event list
|
||||||
|
|
||||||
|
# Search events by text
|
||||||
|
$SKILL_DIR/scripts/calendar.sh event list --search "Allergy"
|
||||||
|
|
||||||
|
# List with UIDs (for deletion)
|
||||||
|
$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 by summary match
|
||||||
|
$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot (Tue)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Delete Safety
|
||||||
|
|
||||||
|
- Deletes only ONE event at a time. If multiple events match, it lists them and exits.
|
||||||
|
- **NEVER use `rm` on calendar .ics files directly.** Always use `event delete`.
|
||||||
|
- After deleting, verify with `event list` or `khal list`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Replying to Invites
|
## Replying to Invites
|
||||||
|
|||||||
@@ -293,9 +293,98 @@ $SKILL_DIR/scripts/calendar.sh todo check
|
|||||||
# Should produce no output
|
# Should produce no output
|
||||||
```
|
```
|
||||||
|
|
||||||
## 14. Regression: Existing Invite Commands
|
## 14. Dry Run: Recurring Event (--rrule)
|
||||||
|
|
||||||
Verify the rename didn't break VEVENT flow.
|
Test recurring event generation. Use a date that falls on a Tuesday.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find next Tuesday
|
||||||
|
NEXT_TUE=$(python3 -c "from datetime import date,timedelta; d=date.today(); d+=timedelta((1-d.weekday())%7 or 7); print(d)")
|
||||||
|
|
||||||
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
|
--to "mail@luyx.org" \
|
||||||
|
--subject "Recurring Test (Tue)" \
|
||||||
|
--summary "Recurring Test (Tue)" \
|
||||||
|
--start "${NEXT_TUE}T14:30:00" \
|
||||||
|
--end "${NEXT_TUE}T15:00:00" \
|
||||||
|
--rrule "FREQ=WEEKLY;COUNT=4;BYDAY=TU" \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
- [ ] ICS has `RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=4`
|
||||||
|
- [ ] DTSTART falls on a Tuesday
|
||||||
|
- [ ] No validation errors
|
||||||
|
|
||||||
|
## 15. Validation: DTSTART/BYDAY Mismatch
|
||||||
|
|
||||||
|
Verify the tool rejects mismatched DTSTART and BYDAY.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# This should FAIL — start is on a Tuesday but BYDAY=TH
|
||||||
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
|
--to "mail@luyx.org" \
|
||||||
|
--subject "Mismatch Test" \
|
||||||
|
--summary "Mismatch Test" \
|
||||||
|
--start "${NEXT_TUE}T09:00:00" \
|
||||||
|
--end "${NEXT_TUE}T09:30:00" \
|
||||||
|
--rrule "FREQ=WEEKLY;COUNT=4;BYDAY=TH" \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
- [ ] Script exits with error
|
||||||
|
- [ ] Error message says DTSTART falls on TU but RRULE says BYDAY=TH
|
||||||
|
- [ ] Suggests changing --start to a date that falls on TH
|
||||||
|
|
||||||
|
## 16. Event List
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List upcoming events
|
||||||
|
$SKILL_DIR/scripts/calendar.sh event list
|
||||||
|
|
||||||
|
# Search by text
|
||||||
|
$SKILL_DIR/scripts/calendar.sh event list --search "Calendar Skill Test"
|
||||||
|
|
||||||
|
# List with UIDs
|
||||||
|
$SKILL_DIR/scripts/calendar.sh event list --format "{uid} {title}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
- [ ] Events from earlier tests appear
|
||||||
|
- [ ] Search narrows results correctly
|
||||||
|
- [ ] UIDs are displayed with --format
|
||||||
|
|
||||||
|
## 17. Event Delete
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Send a throwaway event first
|
||||||
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
|
--to "mail@luyx.org" \
|
||||||
|
--subject "Delete Me Event" \
|
||||||
|
--summary "Delete Me Event" \
|
||||||
|
--start "${TEST_DATE}T20:00:00" \
|
||||||
|
--end "${TEST_DATE}T21:00:00"
|
||||||
|
|
||||||
|
# Verify it exists
|
||||||
|
$SKILL_DIR/scripts/calendar.sh event list --search "Delete Me"
|
||||||
|
|
||||||
|
# Delete it
|
||||||
|
$SKILL_DIR/scripts/calendar.sh event delete --match "Delete Me Event"
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
$SKILL_DIR/scripts/calendar.sh event list --search "Delete Me"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
- [ ] Event is created and visible
|
||||||
|
- [ ] Delete removes exactly one event
|
||||||
|
- [ ] Other events are untouched
|
||||||
|
- [ ] `vdirsyncer sync` ran after delete
|
||||||
|
|
||||||
|
## 18. Regression: Existing Invite Commands
|
||||||
|
|
||||||
|
Verify new features didn't break VEVENT flow.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$SKILL_DIR/scripts/calendar.sh send \
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
@@ -309,7 +398,8 @@ $SKILL_DIR/scripts/calendar.sh send \
|
|||||||
|
|
||||||
**Verify:**
|
**Verify:**
|
||||||
- [ ] ICS has `BEGIN:VEVENT`, `METHOD:REQUEST`
|
- [ ] ICS has `BEGIN:VEVENT`, `METHOD:REQUEST`
|
||||||
- [ ] No errors from the renamed script
|
- [ ] No RRULE present (single event)
|
||||||
|
- [ ] No errors
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -347,3 +437,7 @@ todo list
|
|||||||
| `todo` command not found | Install with `uv tool install todoman` |
|
| `todo` command not found | Install with `uv tool install todoman` |
|
||||||
| `todo list` errors | Check `~/.config/todoman/config.py` exists and `path` points to tasks dir |
|
| `todo list` errors | Check `~/.config/todoman/config.py` exists and `path` points to tasks dir |
|
||||||
| Todo not syncing | Check `~/.openclaw/workspace/calendars/tasks/` exists, verify vdirsyncer `cal/tasks` pair |
|
| Todo not syncing | Check `~/.openclaw/workspace/calendars/tasks/` exists, verify vdirsyncer `cal/tasks` pair |
|
||||||
|
| DTSTART/BYDAY mismatch error | `--start` date doesn't fall on the BYDAY day. Change the start date to match |
|
||||||
|
| 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 |
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ Uses the icalendar library for proper RFC 5545 ICS generation and parsing.
|
|||||||
Uses himalaya CLI for email delivery. Syncs to local CalDAV via vdirsyncer.
|
Uses himalaya CLI for email delivery. Syncs to local CalDAV via vdirsyncer.
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
python calendar.py send [options] # create and send an invite
|
python calendar.py send [options] # create and send an invite (supports --rrule)
|
||||||
python calendar.py reply [options] # accept/decline/tentative
|
python calendar.py reply [options] # accept/decline/tentative
|
||||||
|
python calendar.py event list [options] # list/search calendar events
|
||||||
|
python calendar.py event delete [options] # delete an event by UID or summary
|
||||||
python calendar.py todo add [options] # create a VTODO task
|
python calendar.py todo add [options] # create a VTODO task
|
||||||
python calendar.py todo list [options] # list pending tasks
|
python calendar.py todo list [options] # list pending tasks
|
||||||
python calendar.py todo edit [options] # edit a task's fields
|
python calendar.py todo edit [options] # edit a task's fields
|
||||||
@@ -27,7 +29,7 @@ from email.mime.base import MIMEBase
|
|||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vText
|
from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vRecur, vText
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Config
|
# Config
|
||||||
@@ -111,6 +113,54 @@ def _parse_date(date_str):
|
|||||||
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||||
|
|
||||||
|
|
||||||
|
# iCalendar day abbreviation -> Python weekday (0=Mon, 6=Sun)
|
||||||
|
_ICAL_DAY_TO_WEEKDAY = {
|
||||||
|
"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_rrule_dtstart(rrule_dict, dtstart):
|
||||||
|
"""Validate that DTSTART day-of-week matches BYDAY when FREQ=WEEKLY.
|
||||||
|
|
||||||
|
RFC 5545: 'The DTSTART property value SHOULD be synchronized with the
|
||||||
|
recurrence rule. The recurrence set generated with a DTSTART property
|
||||||
|
value not synchronized with the recurrence rule is undefined.'
|
||||||
|
"""
|
||||||
|
freq = rrule_dict.get("FREQ")
|
||||||
|
if not freq:
|
||||||
|
return
|
||||||
|
# Normalize: icalendar may return freq as list or string
|
||||||
|
if isinstance(freq, list):
|
||||||
|
freq = freq[0]
|
||||||
|
if str(freq).upper() != "WEEKLY":
|
||||||
|
return
|
||||||
|
|
||||||
|
byday = rrule_dict.get("BYDAY")
|
||||||
|
if not byday:
|
||||||
|
return
|
||||||
|
if not isinstance(byday, list):
|
||||||
|
byday = [byday]
|
||||||
|
|
||||||
|
# Only validate alignment when there's a single BYDAY value
|
||||||
|
if len(byday) != 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
day_str = str(byday[0]).upper()
|
||||||
|
expected_weekday = _ICAL_DAY_TO_WEEKDAY.get(day_str)
|
||||||
|
if expected_weekday is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if dtstart.weekday() != expected_weekday:
|
||||||
|
actual_day = [k for k, v in _ICAL_DAY_TO_WEEKDAY.items() if v == dtstart.weekday()][0]
|
||||||
|
print(
|
||||||
|
f"Error: DTSTART falls on {actual_day} but RRULE says BYDAY={day_str}. "
|
||||||
|
f"RFC 5545 requires these to match for FREQ=WEEKLY. "
|
||||||
|
f"Change --start to a date that falls on {day_str}.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Send invite
|
# Send invite
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -154,6 +204,12 @@ def cmd_send(args):
|
|||||||
"RSVP": "TRUE",
|
"RSVP": "TRUE",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Recurrence rule
|
||||||
|
if args.rrule:
|
||||||
|
rrule = vRecur.from_ical(args.rrule)
|
||||||
|
_validate_rrule_dtstart(rrule, start)
|
||||||
|
event.add("rrule", rrule)
|
||||||
|
|
||||||
# 1-day reminder
|
# 1-day reminder
|
||||||
alarm = Alarm()
|
alarm = Alarm()
|
||||||
alarm.add("action", "DISPLAY")
|
alarm.add("action", "DISPLAY")
|
||||||
@@ -683,6 +739,81 @@ def cmd_todo_check(args):
|
|||||||
print(output)
|
print(output)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Event management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_event_list(args):
|
||||||
|
"""List calendar events via khal."""
|
||||||
|
if args.search:
|
||||||
|
cmd = ["khal", "search"]
|
||||||
|
if args.format:
|
||||||
|
cmd += ["--format", args.format]
|
||||||
|
cmd.append(args.search)
|
||||||
|
else:
|
||||||
|
cmd = ["khal", "list"]
|
||||||
|
if args.format:
|
||||||
|
cmd += ["--format", args.format]
|
||||||
|
cmd += [args.range_start, args.range_end]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
print(result.stdout.rstrip())
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error: khal failed: {e.stderr.strip()}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("Error: khal is not installed", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_event_delete(args):
|
||||||
|
"""Delete a calendar event by UID or summary match."""
|
||||||
|
if not args.uid and not args.match:
|
||||||
|
print("Error: --uid or --match is required", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not CALENDAR_DIR.is_dir():
|
||||||
|
print(f"Error: Calendar directory not found: {CALENDAR_DIR}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
ics_files = list(CALENDAR_DIR.glob("*.ics"))
|
||||||
|
if not ics_files:
|
||||||
|
print("No events found in calendar.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Find matching event(s)
|
||||||
|
matches = []
|
||||||
|
for ics_path in ics_files:
|
||||||
|
try:
|
||||||
|
cal = Calendar.from_ical(ics_path.read_bytes())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for component in cal.walk():
|
||||||
|
if component.name == "VEVENT":
|
||||||
|
uid = str(component.get("uid", ""))
|
||||||
|
summary = str(component.get("summary", ""))
|
||||||
|
if args.uid and args.uid in uid:
|
||||||
|
matches.append((ics_path, uid, summary))
|
||||||
|
elif args.match and args.match in summary:
|
||||||
|
matches.append((ics_path, uid, summary))
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
target = args.uid or args.match
|
||||||
|
print(f"Error: No event matching '{target}'", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if len(matches) > 1:
|
||||||
|
print(f"Error: Multiple events match:", file=sys.stderr)
|
||||||
|
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})")
|
||||||
|
_sync_calendar()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CLI
|
# CLI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -703,6 +834,7 @@ def main():
|
|||||||
send_p.add_argument("--location", default="", help="Event location")
|
send_p.add_argument("--location", default="", help="Event location")
|
||||||
send_p.add_argument("--description", default="", help="Event description")
|
send_p.add_argument("--description", default="", help="Event description")
|
||||||
send_p.add_argument("--organizer", default="", help="Organizer display name")
|
send_p.add_argument("--organizer", default="", help="Organizer display name")
|
||||||
|
send_p.add_argument("--rrule", default="", help="RRULE string (e.g. FREQ=WEEKLY;COUNT=13;BYDAY=TU)")
|
||||||
send_p.add_argument("--uid", default="", help="Custom event UID")
|
send_p.add_argument("--uid", default="", help="Custom event UID")
|
||||||
send_p.add_argument("--account", default="", help="Himalaya account")
|
send_p.add_argument("--account", default="", help="Himalaya account")
|
||||||
send_p.add_argument("--dry-run", action="store_true", help="Preview without sending")
|
send_p.add_argument("--dry-run", action="store_true", help="Preview without sending")
|
||||||
@@ -718,6 +850,22 @@ def main():
|
|||||||
reply_p.add_argument("--comment", default="", help="Message to include in reply")
|
reply_p.add_argument("--comment", default="", help="Message to include in reply")
|
||||||
reply_p.add_argument("--dry-run", action="store_true", help="Preview without sending")
|
reply_p.add_argument("--dry-run", action="store_true", help="Preview without sending")
|
||||||
|
|
||||||
|
# --- event ---
|
||||||
|
event_p = subparsers.add_parser("event", help="Manage calendar events")
|
||||||
|
event_sub = event_p.add_subparsers(dest="event_command", required=True)
|
||||||
|
|
||||||
|
# event list
|
||||||
|
elist_p = event_sub.add_parser("list", help="List events (via khal)")
|
||||||
|
elist_p.add_argument("--search", default="", help="Search events by text")
|
||||||
|
elist_p.add_argument("--range-start", default="today", help="Start of range (default: today)")
|
||||||
|
elist_p.add_argument("--range-end", default="90d", help="End of range (default: 90d)")
|
||||||
|
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.add_argument("--uid", default="", help="Event UID")
|
||||||
|
edel_p.add_argument("--match", default="", help="Match on summary text")
|
||||||
|
|
||||||
# --- todo ---
|
# --- todo ---
|
||||||
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")
|
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")
|
||||||
todo_sub = todo_p.add_subparsers(dest="todo_command", required=True)
|
todo_sub = todo_p.add_subparsers(dest="todo_command", required=True)
|
||||||
@@ -761,6 +909,11 @@ def main():
|
|||||||
cmd_send(args)
|
cmd_send(args)
|
||||||
elif args.command == "reply":
|
elif args.command == "reply":
|
||||||
cmd_reply(args)
|
cmd_reply(args)
|
||||||
|
elif args.command == "event":
|
||||||
|
if args.event_command == "list":
|
||||||
|
cmd_event_list(args)
|
||||||
|
elif args.event_command == "delete":
|
||||||
|
cmd_event_delete(args)
|
||||||
elif args.command == "todo":
|
elif args.command == "todo":
|
||||||
if args.todo_command == "add":
|
if args.todo_command == "add":
|
||||||
cmd_todo_add(args)
|
cmd_todo_add(args)
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
# calendar — wrapper script for the calendar and todo tool.
|
# calendar — wrapper script for the calendar and todo tool.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./calendar.sh send [options] # send a calendar invite
|
# ./calendar.sh send [options] # send a calendar invite (supports --rrule)
|
||||||
# ./calendar.sh reply [options] # accept/decline/tentative
|
# ./calendar.sh reply [options] # accept/decline/tentative
|
||||||
|
# ./calendar.sh event list [options] # list/search calendar events
|
||||||
|
# ./calendar.sh event delete [options] # delete an event by UID or summary
|
||||||
# ./calendar.sh todo add [options] # create a todo
|
# ./calendar.sh todo add [options] # create a todo
|
||||||
# ./calendar.sh todo list [options] # list pending todos
|
# ./calendar.sh todo list [options] # list pending todos
|
||||||
# ./calendar.sh todo edit [options] # edit a todo's fields
|
# ./calendar.sh todo edit [options] # edit a todo's fields
|
||||||
|
|||||||
Reference in New Issue
Block a user