Compare commits
2 Commits
da26f84947
...
44fbbea29b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44fbbea29b | ||
|
|
ceb7af543b |
@@ -96,7 +96,7 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_
|
|||||||
**状态**: 运行中
|
**状态**: 运行中
|
||||||
**创建**: 2026-03-18
|
**创建**: 2026-03-18
|
||||||
**配置**:
|
**配置**:
|
||||||
- 技能: `~/.openclaw/workspace/skills/calendar-invite/`
|
- 技能: `~/.openclaw/workspace/skills/calendar/`
|
||||||
- 日历数据: `~/.openclaw/workspace/calendars/` (home/work/tasks/journals)
|
- 日历数据: `~/.openclaw/workspace/calendars/` (home/work/tasks/journals)
|
||||||
- CalDAV: Migadu (`cdav.migadu.com`),通过 vdirsyncer 同步
|
- CalDAV: Migadu (`cdav.migadu.com`),通过 vdirsyncer 同步
|
||||||
- 查看日历: khal
|
- 查看日历: khal
|
||||||
@@ -105,7 +105,8 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_
|
|||||||
**功能**:
|
**功能**:
|
||||||
- 发送日历邀请(自动添加 mail@luyx.org 为参与者)
|
- 发送日历邀请(自动添加 mail@luyx.org 为参与者)
|
||||||
- 接受/拒绝/暂定回复邀请(自动转发给 mail@luyx.org)
|
- 接受/拒绝/暂定回复邀请(自动转发给 mail@luyx.org)
|
||||||
- 发送/回复后自动 `vdirsyncer sync` 同步到 CalDAV
|
- VTODO 待办管理(add/list/complete/delete/check)
|
||||||
|
- 发送/回复/待办操作后自动 `vdirsyncer sync` 同步到 CalDAV
|
||||||
- 心跳定期同步日历
|
- 心跳定期同步日历
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -117,7 +118,7 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_
|
|||||||
| 待办提醒 | `~/.openclaw/workspace/scripts/reminder_check.py` |
|
| 待办提醒 | `~/.openclaw/workspace/scripts/reminder_check.py` |
|
||||||
| 邮件处理器 | `~/.openclaw/workspace/scripts/email_processor/` |
|
| 邮件处理器 | `~/.openclaw/workspace/scripts/email_processor/` |
|
||||||
| 待办列表 | `~/.openclaw/workspace/reminders/active.md` |
|
| 待办列表 | `~/.openclaw/workspace/reminders/active.md` |
|
||||||
| 日历邀请 | `~/.openclaw/workspace/skills/calendar-invite/` |
|
| 日历/待办 | `~/.openclaw/workspace/skills/calendar/` |
|
||||||
| 日历数据 | `~/.openclaw/workspace/calendars/` |
|
| 日历数据 | `~/.openclaw/workspace/calendars/` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
24
TOOLS.md
24
TOOLS.md
@@ -115,37 +115,43 @@ agent-browser close
|
|||||||
- `data/pending_emails.json` — 待处理队列
|
- `data/pending_emails.json` — 待处理队列
|
||||||
- `logs/` — 处理日志
|
- `logs/` — 处理日志
|
||||||
|
|
||||||
### Calendar Invite 日历邀请
|
### Calendar 日历 + 待办
|
||||||
|
|
||||||
**文档**: `~/.openclaw/workspace/skills/calendar-invite/SKILL.md`
|
**文档**: `~/.openclaw/workspace/skills/calendar/SKILL.md`
|
||||||
**目录**: `~/.openclaw/workspace/skills/calendar-invite/`
|
**目录**: `~/.openclaw/workspace/skills/calendar/`
|
||||||
**默认发件人**: youlu@luyanxin.com
|
**默认发件人**: youlu@luyanxin.com
|
||||||
**默认时区**: America/Los_Angeles
|
**默认时区**: America/Los_Angeles
|
||||||
**日历数据**: `~/.openclaw/workspace/calendars/home/`
|
**日历数据**: `~/.openclaw/workspace/calendars/home/`(事件)、`calendars/tasks/`(待办)
|
||||||
**运行方式**: `uv run`(依赖 `icalendar` 库)
|
**运行方式**: `uv run`(依赖 `icalendar` 库)
|
||||||
|
|
||||||
**核心用法**:
|
**核心用法**:
|
||||||
```bash
|
```bash
|
||||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite
|
SKILL_DIR=~/.openclaw/workspace/skills/calendar
|
||||||
|
|
||||||
# 发送日历邀请(--from 默认 youlu@luyanxin.com)
|
# 发送日历邀请(--from 默认 youlu@luyanxin.com)
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
--to "friend@example.com" \
|
--to "friend@example.com" \
|
||||||
--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)
|
# 接受邀请(从邮件中提取 .ics)
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply --envelope-id 42 --action accept
|
$SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action accept
|
||||||
|
|
||||||
# 拒绝邀请(附带留言)
|
# 拒绝邀请(附带留言)
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply --envelope-id 42 --action decline \
|
$SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action decline \
|
||||||
--comment "Sorry, I have a conflict."
|
--comment "Sorry, I have a conflict."
|
||||||
|
|
||||||
|
# 待办管理
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo add --summary "跟进报销" --due "2026-03-25" --priority high
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo list
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo complete --match "报销"
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo check # 每日摘要(cron)
|
||||||
|
|
||||||
# 查看日历(检查冲突)
|
# 查看日历(检查冲突)
|
||||||
khal list today 7d
|
khal list today 7d
|
||||||
```
|
```
|
||||||
|
|
||||||
**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)
|
**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、待办管理 (`todo add/list/complete/delete/check`)
|
||||||
**依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)
|
**依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)
|
||||||
**同步**: 发送/回复后自动 `vdirsyncer sync`,心跳也会定期同步
|
**同步**: 发送/回复后自动 `vdirsyncer sync`,心跳也会定期同步
|
||||||
**自动抄送**: mail@luyx.org(用户别名)自动加入所有邀请
|
**自动抄送**: mail@luyx.org(用户别名)自动加入所有邀请
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# calendar-invite — wrapper script for the calendar invite tool.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./calendar-invite.sh send [options] # send a calendar invite
|
|
||||||
# ./calendar-invite.sh reply [options] # accept/decline/tentative
|
|
||||||
#
|
|
||||||
# Requires: uv, himalaya, vdirsyncer (for CalDAV sync).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
|
|
||||||
exec uv run --project "$SKILL_DIR" python "$SCRIPT_DIR/calendar_invite.py" "$@"
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
name: calendar-invite
|
name: calendar
|
||||||
description: "Send, accept, and decline calendar invite emails (ICS/iCalendar) via himalaya. Syncs events to CalDAV (Migadu) via vdirsyncer."
|
description: "Calendar invites and VTODO task management via CalDAV. Send/reply to invites, create/list/complete/delete todos. Syncs to Migadu CalDAV via vdirsyncer."
|
||||||
metadata: {"clawdbot":{"emoji":"📅","requires":{"bins":["himalaya","vdirsyncer"],"skills":["himalaya"]}}}
|
metadata: {"clawdbot":{"emoji":"📅","requires":{"bins":["himalaya","vdirsyncer"],"skills":["himalaya"]}}}
|
||||||
---
|
---
|
||||||
|
|
||||||
# Calendar Invite
|
# Calendar
|
||||||
|
|
||||||
Send, accept, and decline calendar invitations via email using himalaya. Events are saved to local calendar and synced to CalDAV (Migadu) via vdirsyncer.
|
Send, accept, and decline calendar invitations via email. Create and manage VTODO tasks with CalDAV sync. Events and tasks sync to Migadu CalDAV via vdirsyncer.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -36,13 +36,20 @@ When accepting or tentatively accepting a received invite, the original invite i
|
|||||||
All commands go through the wrapper script:
|
All commands go through the wrapper script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite
|
SKILL_DIR=~/.openclaw/workspace/skills/calendar
|
||||||
|
|
||||||
# Send an invite
|
# Send an invite
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send [options]
|
$SKILL_DIR/scripts/calendar.sh send [options]
|
||||||
|
|
||||||
# Reply to an invite
|
# Reply to an invite
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply [options]
|
$SKILL_DIR/scripts/calendar.sh reply [options]
|
||||||
|
|
||||||
|
# Manage todos
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo add [options]
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo list [options]
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo complete [options]
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo delete [options]
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo check
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -50,7 +57,7 @@ $SKILL_DIR/scripts/calendar-invite.sh reply [options]
|
|||||||
## Sending Invites
|
## Sending Invites
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
--to "friend@example.com" \
|
--to "friend@example.com" \
|
||||||
--subject "Lunch on Friday" \
|
--subject "Lunch on Friday" \
|
||||||
--summary "Lunch at Tartine" \
|
--summary "Lunch at Tartine" \
|
||||||
@@ -81,7 +88,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Simple invite (--from and --timezone default to youlu@luyanxin.com / LA)
|
# Simple invite (--from and --timezone default to youlu@luyanxin.com / LA)
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
--to "alice@example.com" \
|
--to "alice@example.com" \
|
||||||
--subject "Coffee Chat" \
|
--subject "Coffee Chat" \
|
||||||
--summary "Coffee Chat" \
|
--summary "Coffee Chat" \
|
||||||
@@ -89,7 +96,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
|
|||||||
--end "2026-03-25T10:30:00"
|
--end "2026-03-25T10:30:00"
|
||||||
|
|
||||||
# Multiple attendees with details
|
# Multiple attendees with details
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
--to "alice@example.com, bob@example.com" \
|
--to "alice@example.com, bob@example.com" \
|
||||||
--subject "Team Sync" \
|
--subject "Team Sync" \
|
||||||
--summary "Weekly Team Sync" \
|
--summary "Weekly Team Sync" \
|
||||||
@@ -99,7 +106,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
|
|||||||
--description "Weekly check-in. Agenda: updates, blockers, action items."
|
--description "Weekly check-in. Agenda: updates, blockers, action items."
|
||||||
|
|
||||||
# Dry run
|
# Dry run
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
--to "test@example.com" \
|
--to "test@example.com" \
|
||||||
--subject "Test" \
|
--subject "Test" \
|
||||||
--summary "Test Event" \
|
--summary "Test Event" \
|
||||||
@@ -114,18 +121,18 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Accept by himalaya envelope ID
|
# Accept by himalaya envelope ID
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
$SKILL_DIR/scripts/calendar.sh reply \
|
||||||
--envelope-id 42 \
|
--envelope-id 42 \
|
||||||
--action accept
|
--action accept
|
||||||
|
|
||||||
# Decline with a comment
|
# Decline with a comment
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
$SKILL_DIR/scripts/calendar.sh reply \
|
||||||
--envelope-id 42 \
|
--envelope-id 42 \
|
||||||
--action decline \
|
--action decline \
|
||||||
--comment "Sorry, I have a conflict."
|
--comment "Sorry, I have a conflict."
|
||||||
|
|
||||||
# From an .ics file
|
# From an .ics file
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
$SKILL_DIR/scripts/calendar.sh reply \
|
||||||
--ics-file ~/Downloads/meeting.ics \
|
--ics-file ~/Downloads/meeting.ics \
|
||||||
--action tentative
|
--action tentative
|
||||||
```
|
```
|
||||||
@@ -149,7 +156,74 @@ $SKILL_DIR/scripts/calendar-invite.sh reply \
|
|||||||
|
|
||||||
1. List emails: `himalaya envelope list`
|
1. List emails: `himalaya envelope list`
|
||||||
2. Read the invite: `himalaya message read 57`
|
2. Read the invite: `himalaya message read 57`
|
||||||
3. Reply: `$SKILL_DIR/scripts/calendar-invite.sh reply --envelope-id 57 --action accept`
|
3. Reply: `$SKILL_DIR/scripts/calendar.sh reply --envelope-id 57 --action accept`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VTODO Tasks
|
||||||
|
|
||||||
|
Manage tasks as RFC 5545 VTODO components, stored in `~/.openclaw/workspace/calendars/tasks/` and synced to CalDAV.
|
||||||
|
|
||||||
|
### Sync Model
|
||||||
|
|
||||||
|
The agent's local CalDAV is the **source of truth** (no two-way sync). When a todo is created, it's saved locally and emailed to `mail@luyx.org` as a delivery copy. When the user completes a task, they tell the agent, and the agent runs `todo complete`. The daily `todo check` cron reads from local files.
|
||||||
|
|
||||||
|
### Priority Mapping (RFC 5545)
|
||||||
|
|
||||||
|
| Label | `--priority` | RFC 5545 value |
|
||||||
|
|--------|-------------|----------------|
|
||||||
|
| 高 (high) | `high` | 1 |
|
||||||
|
| 中 (medium) | `medium` | 5 (default) |
|
||||||
|
| 低 (low) | `low` | 9 |
|
||||||
|
|
||||||
|
### `todo add` — Create a Todo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo add \
|
||||||
|
--summary "跟进iui保险报销" \
|
||||||
|
--due "2026-03-25" \
|
||||||
|
--priority high \
|
||||||
|
--description "确认iui费用保险报销进度" \
|
||||||
|
--alarm 1d
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Required | Description |
|
||||||
|
|-----------------|----------|-----------------------------------------------------|
|
||||||
|
| `--summary` | Yes | Todo title |
|
||||||
|
| `--due` | No | Due date, YYYY-MM-DD (default: tomorrow) |
|
||||||
|
| `--priority` | No | `high`, `medium`, or `low` (default: `medium`) |
|
||||||
|
| `--description` | No | Notes / description |
|
||||||
|
| `--alarm` | No | Reminder trigger: `1d`, `2h`, `30m` (default: `1d`) |
|
||||||
|
| `--dry-run` | No | Preview ICS + email without saving |
|
||||||
|
|
||||||
|
### `todo list` — List Todos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo list # pending only
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo list --all # include completed
|
||||||
|
```
|
||||||
|
|
||||||
|
### `todo complete` — Mark as Done
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo complete --uid "abc123@openclaw"
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo complete --match "保险报销"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `todo delete` — Remove a Todo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo delete --uid "abc123@openclaw"
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo delete --match "保险报销"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `todo check` — Daily Digest (Cron)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo check
|
||||||
|
```
|
||||||
|
|
||||||
|
Same as `todo list` but only NEEDS-ACTION items. Exits silently when no pending items. Output is designed for piping to himalaya.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -170,8 +244,15 @@ $SKILL_DIR/scripts/calendar-invite.sh reply \
|
|||||||
5. On accept/tentative: saves event to local calendar. On decline: removes it
|
5. On accept/tentative: saves event to local calendar. On decline: removes it
|
||||||
6. Runs `vdirsyncer sync` to push changes to Migadu CalDAV
|
6. Runs `vdirsyncer sync` to push changes to Migadu CalDAV
|
||||||
|
|
||||||
|
**Managing todos:**
|
||||||
|
1. Creates a VTODO ICS file with `STATUS:NEEDS-ACTION`, `PRIORITY`, `DUE`, `VALARM`
|
||||||
|
2. Saves to `~/.openclaw/workspace/calendars/tasks/`
|
||||||
|
3. Emails the VTODO to `mail@luyx.org` as a delivery copy
|
||||||
|
4. Runs `vdirsyncer sync` to push to Migadu CalDAV
|
||||||
|
5. `todo check` reads local files for the daily cron digest
|
||||||
|
|
||||||
**CalDAV sync:**
|
**CalDAV sync:**
|
||||||
- Events sync to Migadu and appear on all connected devices (DAVx5, etc.)
|
- Events and tasks sync to Migadu and appear on all connected devices (DAVx5, etc.)
|
||||||
- Heartbeat runs `vdirsyncer sync` periodically as a fallback
|
- Heartbeat runs `vdirsyncer sync` periodically as a fallback
|
||||||
- If sync fails, it warns but doesn't block — next heartbeat catches up
|
- If sync fails, it warns but doesn't block — next heartbeat catches up
|
||||||
|
|
||||||
@@ -222,6 +303,11 @@ Common IANA timezones:
|
|||||||
- Check `~/.openclaw/workspace/logs/vdirsyncer.log` for errors
|
- Check `~/.openclaw/workspace/logs/vdirsyncer.log` for errors
|
||||||
- Verify the .ics file exists in `~/.openclaw/workspace/calendars/home/`
|
- Verify the .ics file exists in `~/.openclaw/workspace/calendars/home/`
|
||||||
|
|
||||||
|
**Todos not syncing?**
|
||||||
|
- Check that `~/.openclaw/workspace/calendars/tasks/` exists
|
||||||
|
- Verify vdirsyncer has a `cal/tasks` pair configured
|
||||||
|
- Run `vdirsyncer sync` manually
|
||||||
|
|
||||||
**Recipient doesn't see Accept/Decline?**
|
**Recipient doesn't see Accept/Decline?**
|
||||||
- Gmail, Outlook, Apple Mail all support `text/calendar` method=REQUEST
|
- Gmail, Outlook, Apple Mail all support `text/calendar` method=REQUEST
|
||||||
- Some webmail clients may vary
|
- Some webmail clients may vary
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Testing the Calendar Invite Skill
|
# Testing the Calendar Skill
|
||||||
|
|
||||||
End-to-end tests for send, reply, calendar sync, and local calendar. All commands use `--dry-run` first, then live.
|
End-to-end tests for send, reply, todo, calendar sync, and local calendar. All commands use `--dry-run` first, then live.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite
|
SKILL_DIR=~/.openclaw/workspace/skills/calendar
|
||||||
|
|
||||||
# Use a date 3 days from now for test events
|
# Use a date 3 days from now for test events
|
||||||
TEST_DATE=$(date -d "+3 days" +%Y-%m-%d)
|
TEST_DATE=$(date -d "+3 days" +%Y-%m-%d)
|
||||||
@@ -20,7 +20,7 @@ Generates the ICS and MIME email without sending. Check that:
|
|||||||
- Times and timezone look correct
|
- Times and timezone look correct
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
--to "mail@luyx.org" \
|
--to "mail@luyx.org" \
|
||||||
--subject "Test Invite" \
|
--subject "Test Invite" \
|
||||||
--summary "Test Event" \
|
--summary "Test Event" \
|
||||||
@@ -34,7 +34,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
|
|||||||
Send a real invite to `mail@luyx.org` only (no confirmation needed per email rules).
|
Send a real invite to `mail@luyx.org` only (no confirmation needed per email rules).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
--to "mail@luyx.org" \
|
--to "mail@luyx.org" \
|
||||||
--subject "Calendar Skill Test" \
|
--subject "Calendar Skill Test" \
|
||||||
--summary "Calendar Skill Test" \
|
--summary "Calendar Skill Test" \
|
||||||
@@ -82,7 +82,7 @@ himalaya envelope list
|
|||||||
himalaya message read <envelope-id>
|
himalaya message read <envelope-id>
|
||||||
|
|
||||||
# Accept it
|
# Accept it
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
$SKILL_DIR/scripts/calendar.sh reply \
|
||||||
--envelope-id <envelope-id> \
|
--envelope-id <envelope-id> \
|
||||||
--action accept
|
--action accept
|
||||||
```
|
```
|
||||||
@@ -100,7 +100,7 @@ Send another self-invite, then decline it. This verifies decline removes the eve
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Send a second test invite
|
# Send a second test invite
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
--to "mail@luyx.org" \
|
--to "mail@luyx.org" \
|
||||||
--subject "Decline Test" \
|
--subject "Decline Test" \
|
||||||
--summary "Decline Test Event" \
|
--summary "Decline Test Event" \
|
||||||
@@ -111,7 +111,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
|
|||||||
himalaya envelope list
|
himalaya envelope list
|
||||||
|
|
||||||
# Decline it
|
# Decline it
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
$SKILL_DIR/scripts/calendar.sh reply \
|
||||||
--envelope-id <envelope-id> \
|
--envelope-id <envelope-id> \
|
||||||
--action decline \
|
--action decline \
|
||||||
--comment "Testing decline flow."
|
--comment "Testing decline flow."
|
||||||
@@ -140,6 +140,128 @@ khal list today 7d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 7. Dry Run: Add Todo
|
||||||
|
|
||||||
|
Generates the VTODO ICS and MIME email without saving. Check that:
|
||||||
|
- ICS has `BEGIN:VTODO`
|
||||||
|
- ICS has correct `PRIORITY` value (1 for high)
|
||||||
|
- ICS has `STATUS:NEEDS-ACTION`
|
||||||
|
- ICS has `BEGIN:VALARM`
|
||||||
|
- MIME has `Content-Type: text/calendar`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo add \
|
||||||
|
--summary "Test Todo" \
|
||||||
|
--due "$TEST_DATE" \
|
||||||
|
--priority high \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Live Add: Create a Todo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo add \
|
||||||
|
--summary "Test Todo" \
|
||||||
|
--due "$TEST_DATE" \
|
||||||
|
--priority medium \
|
||||||
|
--description "Test description"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
- [ ] Script exits without error
|
||||||
|
- [ ] `.ics` file created in `~/.openclaw/workspace/calendars/tasks/`
|
||||||
|
- [ ] Email arrives at `mail@luyx.org` with .ics attachment
|
||||||
|
- [ ] `vdirsyncer sync` ran
|
||||||
|
|
||||||
|
## 9. List Todos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List pending todos
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo list
|
||||||
|
|
||||||
|
# List all (including completed)
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo list --all
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
- [ ] "Test Todo" appears with correct urgency label
|
||||||
|
- [ ] Priority grouping is correct
|
||||||
|
- [ ] `--all` flag works (same output when none are completed)
|
||||||
|
|
||||||
|
## 10. Complete a Todo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo complete --match "Test Todo"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
- [ ] .ics file updated with `STATUS:COMPLETED` and `COMPLETED:` timestamp
|
||||||
|
- [ ] `todo list` — "Test Todo" no longer appears
|
||||||
|
- [ ] `todo list --all` — "Test Todo" appears as completed (with checkmark)
|
||||||
|
- [ ] `vdirsyncer sync` ran
|
||||||
|
|
||||||
|
## 11. Delete a Todo
|
||||||
|
|
||||||
|
Create a second test todo, then delete it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo add \
|
||||||
|
--summary "Delete Me Todo" \
|
||||||
|
--due "$TEST_DATE" \
|
||||||
|
--priority low
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo delete --match "Delete Me"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
- [ ] .ics file removed from tasks dir
|
||||||
|
- [ ] `todo list` does not show "Delete Me Todo"
|
||||||
|
- [ ] `vdirsyncer sync` ran
|
||||||
|
|
||||||
|
## 12. Todo Check (Cron Output)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a test todo
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo add \
|
||||||
|
--summary "Check Test Todo" \
|
||||||
|
--due "$TEST_DATE"
|
||||||
|
|
||||||
|
# Run check
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo check
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
- [ ] Output matches daily digest format (priority groups, urgency labels)
|
||||||
|
- [ ] Complete the todo, run `todo check` again — silent exit (no output)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo complete --match "Check Test"
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo check
|
||||||
|
# Should produce no output
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. Regression: Existing Invite Commands
|
||||||
|
|
||||||
|
Verify the rename didn't break VEVENT flow.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
|
--to "test@example.com" \
|
||||||
|
--subject "Regression Test" \
|
||||||
|
--summary "Regression Test Event" \
|
||||||
|
--start "${TEST_DATE}T10:00:00" \
|
||||||
|
--end "${TEST_DATE}T11:00:00" \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
- [ ] ICS has `BEGIN:VEVENT`, `METHOD:REQUEST`
|
||||||
|
- [ ] No errors from the renamed script
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick Health Checks
|
## Quick Health Checks
|
||||||
|
|
||||||
Run these first if any step fails.
|
Run these first if any step fails.
|
||||||
@@ -168,3 +290,4 @@ khal list today 7d
|
|||||||
| `ModuleNotFoundError: icalendar` | Run `uv sync --project $SKILL_DIR` to install dependencies |
|
| `ModuleNotFoundError: icalendar` | Run `uv sync --project $SKILL_DIR` to install dependencies |
|
||||||
| Invite shows as attachment (no Accept/Decline) | Check MIME `Content-Type` includes `method=REQUEST` |
|
| Invite shows as attachment (no Accept/Decline) | Check MIME `Content-Type` includes `method=REQUEST` |
|
||||||
| Event not in `khal list` after sync | Check `.ics` file exists in `~/.openclaw/workspace/calendars/home/` |
|
| Event not in `khal list` after sync | Check `.ics` file exists in `~/.openclaw/workspace/calendars/home/` |
|
||||||
|
| Todo not syncing | Check `~/.openclaw/workspace/calendars/tasks/` exists, verify vdirsyncer `cal/tasks` pair |
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"ownerId": "kn7anq2d7gcch060anc2j9cg89800dyv",
|
"ownerId": "kn7anq2d7gcch060anc2j9cg89800dyv",
|
||||||
"slug": "calendar-invite",
|
"slug": "calendar",
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "calendar-invite"
|
name = "calendar"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = ["icalendar"]
|
dependencies = ["icalendar"]
|
||||||
@@ -1,27 +1,32 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Calendar Invite — Send, accept, and decline calendar invites via himalaya.
|
Calendar — Send/reply to calendar invites and manage VTODO tasks via CalDAV.
|
||||||
|
|
||||||
Uses the icalendar library for proper RFC 5545 ICS generation and parsing.
|
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_invite.py send [options] # create and send an invite
|
python calendar.py send [options] # create and send an invite
|
||||||
python calendar_invite.py reply [options] # accept/decline/tentative
|
python calendar.py reply [options] # accept/decline/tentative
|
||||||
|
python calendar.py todo add [options] # create a VTODO task
|
||||||
|
python calendar.py todo list [options] # list pending tasks
|
||||||
|
python calendar.py todo complete [options] # mark task as done
|
||||||
|
python calendar.py todo delete [options] # remove a task
|
||||||
|
python calendar.py todo check # daily digest for cron
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from email.mime.base import MIMEBase
|
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, vCalAddress, vText
|
from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vText
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Config
|
# Config
|
||||||
@@ -31,7 +36,12 @@ DEFAULT_TIMEZONE = "America/Los_Angeles"
|
|||||||
DEFAULT_FROM = "youlu@luyanxin.com"
|
DEFAULT_FROM = "youlu@luyanxin.com"
|
||||||
DEFAULT_OWNER_EMAIL = "mail@luyx.org" # Always added as attendee
|
DEFAULT_OWNER_EMAIL = "mail@luyx.org" # Always added as attendee
|
||||||
CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home"
|
CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home"
|
||||||
PRODID = "-//OpenClaw//CalendarInvite//EN"
|
TASKS_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "tasks"
|
||||||
|
PRODID = "-//OpenClaw//Calendar//EN"
|
||||||
|
|
||||||
|
# RFC 5545 priority mapping
|
||||||
|
PRIORITY_MAP = {"high": 1, "medium": 5, "low": 9}
|
||||||
|
PRIORITY_LABELS = {1: "高", 5: "中", 9: "低"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -96,6 +106,11 @@ def _parse_iso_datetime(dt_str):
|
|||||||
raise ValueError(f"Cannot parse datetime: {dt_str}")
|
raise ValueError(f"Cannot parse datetime: {dt_str}")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(date_str):
|
||||||
|
"""Parse YYYY-MM-DD date string."""
|
||||||
|
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Send invite
|
# Send invite
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -370,12 +385,359 @@ def cmd_reply(args):
|
|||||||
cleanup_dir.rmdir()
|
cleanup_dir.rmdir()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VTODO: helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _load_todos():
|
||||||
|
"""Load all VTODO items from TASKS_DIR. Returns list of (path, vtodo) tuples."""
|
||||||
|
if not TASKS_DIR.is_dir():
|
||||||
|
return []
|
||||||
|
todos = []
|
||||||
|
for ics_path in TASKS_DIR.glob("*.ics"):
|
||||||
|
try:
|
||||||
|
cal = Calendar.from_ical(ics_path.read_bytes())
|
||||||
|
for component in cal.walk():
|
||||||
|
if component.name == "VTODO":
|
||||||
|
todos.append((ics_path, component))
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return todos
|
||||||
|
|
||||||
|
|
||||||
|
def _get_due_date(vtodo):
|
||||||
|
"""Extract due date from a VTODO as a date object, or None."""
|
||||||
|
due = vtodo.get("due")
|
||||||
|
if due is None:
|
||||||
|
return None
|
||||||
|
dt = due.dt
|
||||||
|
if isinstance(dt, datetime):
|
||||||
|
return dt.date()
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _get_priority_int(vtodo):
|
||||||
|
"""Get priority as int (1=high, 5=medium, 9=low). Default 5."""
|
||||||
|
p = vtodo.get("priority")
|
||||||
|
if p is None:
|
||||||
|
return 5
|
||||||
|
return int(p)
|
||||||
|
|
||||||
|
|
||||||
|
def _days_until(due_date):
|
||||||
|
"""Days from today until due_date. Negative means overdue."""
|
||||||
|
if due_date is None:
|
||||||
|
return None
|
||||||
|
return (due_date - date.today()).days
|
||||||
|
|
||||||
|
|
||||||
|
def _urgency_label(days):
|
||||||
|
"""Urgency label with emoji, matching reminder_check.py style."""
|
||||||
|
if days is None:
|
||||||
|
return "❓ 日期未知"
|
||||||
|
elif days < 0:
|
||||||
|
return f"🔴 逾期 {-days} 天"
|
||||||
|
elif days == 0:
|
||||||
|
return "🔴 今天"
|
||||||
|
elif days == 1:
|
||||||
|
return "🟡 明天"
|
||||||
|
elif days <= 3:
|
||||||
|
return f"🟡 {days} 天后"
|
||||||
|
else:
|
||||||
|
return f"🟢 {days} 天后"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_todo_by_match(todos, match_str):
|
||||||
|
"""Find a single todo by fuzzy match on SUMMARY. Exits on 0 or >1 matches."""
|
||||||
|
matches = []
|
||||||
|
for path, vtodo in todos:
|
||||||
|
summary = str(vtodo.get("summary", ""))
|
||||||
|
if match_str in summary:
|
||||||
|
matches.append((path, vtodo))
|
||||||
|
if not matches:
|
||||||
|
print(f"Error: No todo matching '{match_str}'", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if len(matches) > 1:
|
||||||
|
print(f"Error: Multiple todos match '{match_str}':", file=sys.stderr)
|
||||||
|
for _, vt in matches:
|
||||||
|
print(f" - {vt.get('summary')} (UID: {vt.get('uid')})", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_todo_by_uid(todos, uid):
|
||||||
|
"""Find a todo by UID. Exits if not found."""
|
||||||
|
for path, vtodo in todos:
|
||||||
|
if str(vtodo.get("uid", "")) == uid:
|
||||||
|
return path, vtodo
|
||||||
|
print(f"Error: No todo with UID '{uid}'", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_todo(args, todos):
|
||||||
|
"""Resolve a todo from --uid or --match args."""
|
||||||
|
if args.uid:
|
||||||
|
return _find_todo_by_uid(todos, args.uid)
|
||||||
|
elif args.match:
|
||||||
|
return _find_todo_by_match(todos, args.match)
|
||||||
|
else:
|
||||||
|
print("Error: --uid or --match is required", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VTODO: subcommands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_todo_add(args):
|
||||||
|
"""Create a VTODO and save to TASKS_DIR."""
|
||||||
|
TASKS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
uid = f"{uuid.uuid4()}@openclaw"
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Parse due date (default: tomorrow)
|
||||||
|
if args.due:
|
||||||
|
due_date = _parse_date(args.due)
|
||||||
|
else:
|
||||||
|
due_date = date.today() + timedelta(days=1)
|
||||||
|
|
||||||
|
# Parse priority
|
||||||
|
priority = PRIORITY_MAP.get(args.priority, 5)
|
||||||
|
|
||||||
|
# Parse alarm trigger
|
||||||
|
alarm_trigger = timedelta(days=-1) # default: 1 day before
|
||||||
|
if args.alarm:
|
||||||
|
alarm_str = args.alarm
|
||||||
|
if alarm_str.endswith("d"):
|
||||||
|
alarm_trigger = timedelta(days=-int(alarm_str[:-1]))
|
||||||
|
elif alarm_str.endswith("h"):
|
||||||
|
alarm_trigger = timedelta(hours=-int(alarm_str[:-1]))
|
||||||
|
elif alarm_str.endswith("m"):
|
||||||
|
alarm_trigger = timedelta(minutes=-int(alarm_str[:-1]))
|
||||||
|
|
||||||
|
# Build VTODO calendar
|
||||||
|
cal = Calendar()
|
||||||
|
cal.add("prodid", PRODID)
|
||||||
|
cal.add("version", "2.0")
|
||||||
|
cal.add("method", "REQUEST")
|
||||||
|
|
||||||
|
todo = Todo()
|
||||||
|
todo.add("uid", uid)
|
||||||
|
todo.add("dtstamp", now)
|
||||||
|
todo.add("created", now)
|
||||||
|
todo.add("summary", args.summary)
|
||||||
|
todo.add("due", due_date)
|
||||||
|
todo.add("priority", priority)
|
||||||
|
todo.add("status", "NEEDS-ACTION")
|
||||||
|
|
||||||
|
if args.description:
|
||||||
|
todo.add("description", args.description)
|
||||||
|
|
||||||
|
# VALARM reminder
|
||||||
|
alarm = Alarm()
|
||||||
|
alarm.add("action", "DISPLAY")
|
||||||
|
alarm.add("description", f"Todo: {args.summary}")
|
||||||
|
alarm.add("trigger", alarm_trigger)
|
||||||
|
todo.add_component(alarm)
|
||||||
|
|
||||||
|
cal.add_component(todo)
|
||||||
|
ics_bytes = cal.to_ical()
|
||||||
|
|
||||||
|
# Build email body
|
||||||
|
prio_label = PRIORITY_LABELS.get(priority, "中")
|
||||||
|
body = f"待办事项: {args.summary}\n截止日期: {due_date}\n优先级: {prio_label}"
|
||||||
|
if args.description:
|
||||||
|
body += f"\n\n{args.description}"
|
||||||
|
|
||||||
|
# Build MIME email
|
||||||
|
email_str = _build_calendar_email(
|
||||||
|
DEFAULT_FROM, DEFAULT_OWNER_EMAIL,
|
||||||
|
f"📋 待办: {args.summary}",
|
||||||
|
body, ics_bytes, method="REQUEST",
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("=== ICS Content ===")
|
||||||
|
print(ics_bytes.decode())
|
||||||
|
print("=== Email Message ===")
|
||||||
|
print(email_str)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save to TASKS_DIR (without METHOD for CalDAV)
|
||||||
|
dest = TASKS_DIR / f"{uid}.ics"
|
||||||
|
dest.write_bytes(_strip_method(ics_bytes))
|
||||||
|
print(f"Todo created: {args.summary} (due: {due_date}, priority: {prio_label})")
|
||||||
|
print(f"Saved to: {dest}")
|
||||||
|
|
||||||
|
# Sync
|
||||||
|
_sync_calendar()
|
||||||
|
|
||||||
|
# Email the VTODO to owner
|
||||||
|
try:
|
||||||
|
_send_email(email_str)
|
||||||
|
print(f"Emailed todo to {DEFAULT_OWNER_EMAIL}")
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
print(f"Warning: Failed to email todo to {DEFAULT_OWNER_EMAIL}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_todo_list(args):
|
||||||
|
"""List todos, optionally including completed ones."""
|
||||||
|
todos = _load_todos()
|
||||||
|
if not todos:
|
||||||
|
print("No todos found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Filter
|
||||||
|
if not args.all:
|
||||||
|
todos = [(p, vt) for p, vt in todos
|
||||||
|
if str(vt.get("status", "NEEDS-ACTION")) in ("NEEDS-ACTION", "IN-PROCESS")]
|
||||||
|
|
||||||
|
if not todos:
|
||||||
|
print("No pending todos.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Sort by priority then due date
|
||||||
|
def sort_key(item):
|
||||||
|
_, vt = item
|
||||||
|
prio = _get_priority_int(vt)
|
||||||
|
due = _get_due_date(vt)
|
||||||
|
return (prio, due or date.max)
|
||||||
|
|
||||||
|
todos.sort(key=sort_key)
|
||||||
|
|
||||||
|
# Format output
|
||||||
|
today_str = date.today().isoformat()
|
||||||
|
print(f"📋 待办事项 ({today_str})")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Group by priority
|
||||||
|
groups = {1: [], 5: [], 9: []}
|
||||||
|
for path, vt in todos:
|
||||||
|
prio = _get_priority_int(vt)
|
||||||
|
# Bucket into nearest standard priority
|
||||||
|
if prio <= 3:
|
||||||
|
groups[1].append((path, vt))
|
||||||
|
elif prio <= 7:
|
||||||
|
groups[5].append((path, vt))
|
||||||
|
else:
|
||||||
|
groups[9].append((path, vt))
|
||||||
|
|
||||||
|
for prio, emoji, label in [(1, "🔴", "高优先级"), (5, "🟡", "中优先级"), (9, "🟢", "低优先级")]:
|
||||||
|
items = groups[prio]
|
||||||
|
if not items:
|
||||||
|
continue
|
||||||
|
print(f"\n{emoji} {label}:")
|
||||||
|
for _, vt in items:
|
||||||
|
summary = str(vt.get("summary", ""))
|
||||||
|
due = _get_due_date(vt)
|
||||||
|
days = _days_until(due)
|
||||||
|
urgency = _urgency_label(days)
|
||||||
|
status = str(vt.get("status", ""))
|
||||||
|
desc = str(vt.get("description", ""))
|
||||||
|
|
||||||
|
line = f" • {summary} ({urgency})"
|
||||||
|
if status == "COMPLETED":
|
||||||
|
line = f" • ✅ {summary} (已完成)"
|
||||||
|
if desc:
|
||||||
|
line += f" | {desc}"
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_todo_complete(args):
|
||||||
|
"""Mark a todo as COMPLETED."""
|
||||||
|
todos = _load_todos()
|
||||||
|
path, vtodo = _resolve_todo(args, todos)
|
||||||
|
|
||||||
|
# Read, modify, rewrite
|
||||||
|
cal = Calendar.from_ical(path.read_bytes())
|
||||||
|
for component in cal.walk():
|
||||||
|
if component.name == "VTODO":
|
||||||
|
component["status"] = "COMPLETED"
|
||||||
|
component.add("completed", datetime.now(timezone.utc))
|
||||||
|
break
|
||||||
|
|
||||||
|
path.write_bytes(cal.to_ical())
|
||||||
|
summary = str(vtodo.get("summary", ""))
|
||||||
|
print(f"Completed: {summary}")
|
||||||
|
_sync_calendar()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_todo_delete(args):
|
||||||
|
"""Delete a todo .ics file."""
|
||||||
|
todos = _load_todos()
|
||||||
|
path, vtodo = _resolve_todo(args, todos)
|
||||||
|
|
||||||
|
summary = str(vtodo.get("summary", ""))
|
||||||
|
path.unlink()
|
||||||
|
print(f"Deleted: {summary}")
|
||||||
|
_sync_calendar()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_todo_check(args):
|
||||||
|
"""Daily digest of pending todos (for cron). Silent when empty."""
|
||||||
|
todos = _load_todos()
|
||||||
|
# Filter to NEEDS-ACTION only
|
||||||
|
pending = [(p, vt) for p, vt in todos
|
||||||
|
if str(vt.get("status", "NEEDS-ACTION")) in ("NEEDS-ACTION", "IN-PROCESS")]
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
return # silent exit
|
||||||
|
|
||||||
|
# Sort by priority then due date
|
||||||
|
def sort_key(item):
|
||||||
|
_, vt = item
|
||||||
|
prio = _get_priority_int(vt)
|
||||||
|
due = _get_due_date(vt)
|
||||||
|
return (prio, due or date.max)
|
||||||
|
|
||||||
|
pending.sort(key=sort_key)
|
||||||
|
|
||||||
|
# Format output (same style as todo list but without footer)
|
||||||
|
today_str = date.today().isoformat()
|
||||||
|
print(f"📋 待办事项 ({today_str})")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
groups = {1: [], 5: [], 9: []}
|
||||||
|
for path, vt in pending:
|
||||||
|
prio = _get_priority_int(vt)
|
||||||
|
if prio <= 3:
|
||||||
|
groups[1].append((path, vt))
|
||||||
|
elif prio <= 7:
|
||||||
|
groups[5].append((path, vt))
|
||||||
|
else:
|
||||||
|
groups[9].append((path, vt))
|
||||||
|
|
||||||
|
for prio, emoji, label in [(1, "🔴", "高优先级"), (5, "🟡", "中优先级"), (9, "🟢", "低优先级")]:
|
||||||
|
items = groups[prio]
|
||||||
|
if not items:
|
||||||
|
continue
|
||||||
|
print(f"\n{emoji} {label}:")
|
||||||
|
for _, vt in items:
|
||||||
|
summary = str(vt.get("summary", ""))
|
||||||
|
due = _get_due_date(vt)
|
||||||
|
days = _days_until(due)
|
||||||
|
urgency = _urgency_label(days)
|
||||||
|
desc = str(vt.get("description", ""))
|
||||||
|
line = f" • {summary} ({urgency})"
|
||||||
|
if desc:
|
||||||
|
line += f" | {desc}"
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CLI
|
# CLI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Calendar invite tool")
|
parser = argparse.ArgumentParser(description="Calendar and todo tool")
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
# --- send ---
|
# --- send ---
|
||||||
@@ -405,12 +767,53 @@ 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")
|
||||||
|
|
||||||
|
# --- todo ---
|
||||||
|
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")
|
||||||
|
todo_sub = todo_p.add_subparsers(dest="todo_command", required=True)
|
||||||
|
|
||||||
|
# todo add
|
||||||
|
add_p = todo_sub.add_parser("add", help="Create a new todo")
|
||||||
|
add_p.add_argument("--summary", required=True, help="Todo title")
|
||||||
|
add_p.add_argument("--due", default="", help="Due date (YYYY-MM-DD, default: tomorrow)")
|
||||||
|
add_p.add_argument("--priority", default="medium", choices=["high", "medium", "low"], help="Priority")
|
||||||
|
add_p.add_argument("--description", default="", help="Notes / description")
|
||||||
|
add_p.add_argument("--alarm", default="1d", help="Reminder trigger (e.g. 1d, 2h, 30m)")
|
||||||
|
add_p.add_argument("--dry-run", action="store_true", help="Preview without saving")
|
||||||
|
|
||||||
|
# todo list
|
||||||
|
list_p = todo_sub.add_parser("list", help="List todos")
|
||||||
|
list_p.add_argument("--all", action="store_true", help="Include completed todos")
|
||||||
|
|
||||||
|
# todo complete
|
||||||
|
comp_p = todo_sub.add_parser("complete", help="Mark a todo as done")
|
||||||
|
comp_p.add_argument("--uid", default="", help="Todo UID")
|
||||||
|
comp_p.add_argument("--match", default="", help="Fuzzy match on summary")
|
||||||
|
|
||||||
|
# todo delete
|
||||||
|
del_p = todo_sub.add_parser("delete", help="Delete a todo")
|
||||||
|
del_p.add_argument("--uid", default="", help="Todo UID")
|
||||||
|
del_p.add_argument("--match", default="", help="Fuzzy match on summary")
|
||||||
|
|
||||||
|
# todo check
|
||||||
|
todo_sub.add_parser("check", help="Daily digest (for cron)")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == "send":
|
if args.command == "send":
|
||||||
cmd_send(args)
|
cmd_send(args)
|
||||||
elif args.command == "reply":
|
elif args.command == "reply":
|
||||||
cmd_reply(args)
|
cmd_reply(args)
|
||||||
|
elif args.command == "todo":
|
||||||
|
if args.todo_command == "add":
|
||||||
|
cmd_todo_add(args)
|
||||||
|
elif args.todo_command == "list":
|
||||||
|
cmd_todo_list(args)
|
||||||
|
elif args.todo_command == "complete":
|
||||||
|
cmd_todo_complete(args)
|
||||||
|
elif args.todo_command == "delete":
|
||||||
|
cmd_todo_delete(args)
|
||||||
|
elif args.todo_command == "check":
|
||||||
|
cmd_todo_check(args)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
20
skills/calendar/scripts/calendar.sh
Executable file
20
skills/calendar/scripts/calendar.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# calendar — wrapper script for the calendar and todo tool.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./calendar.sh send [options] # send a calendar invite
|
||||||
|
# ./calendar.sh reply [options] # accept/decline/tentative
|
||||||
|
# ./calendar.sh todo add [options] # create a todo
|
||||||
|
# ./calendar.sh todo list [options] # list pending todos
|
||||||
|
# ./calendar.sh todo complete [options] # mark todo as done
|
||||||
|
# ./calendar.sh todo delete [options] # remove a todo
|
||||||
|
# ./calendar.sh todo check # daily digest for cron
|
||||||
|
#
|
||||||
|
# Requires: uv, himalaya, vdirsyncer (for CalDAV sync).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
exec uv run --project "$SKILL_DIR" python "$SCRIPT_DIR/cal_tool.py" "$@"
|
||||||
@@ -3,7 +3,7 @@ revision = 3
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "calendar-invite"
|
name = "calendar"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
Reference in New Issue
Block a user