Compare commits

...

2 Commits

Author SHA1 Message Date
Yanxin Lu
261544b50a docs: update TOOLS.md and MEMORY.md for calendar+todoman migration 2026-03-22 15:02:01 -07:00
Yanxin Lu
383b33cc30 calendar: use todoman for VTODO list/complete/delete/check
Replaces custom .ics parsing with todoman CLI (--porcelain for JSON).
todo add still uses icalendar directly (needs ICS creation + email).
Updates MIGRATION.md with todoman install/config instructions.
2026-03-22 15:00:20 -07:00
5 changed files with 180 additions and 211 deletions

View File

@@ -31,18 +31,12 @@ _这份文件记录持续性项目和重要状态跨会话保留。_
## 🎯 活跃项目 ## 🎯 活跃项目
### 1. 每日待办提醒系统 ### 1. 每日待办提醒系统
**状态**: 运行中 **状态**: 迁移中 → VTODO`skills/calendar/MIGRATION.md`
**创建**: 2026-02-15 **创建**: 2026-02-15
**配置**: **旧系统**(待清理):
- 脚本: `~/.openclaw/workspace/scripts/reminder_check.py` - 脚本: `~/.openclaw/workspace/scripts/reminder_check.py`
- Cron: 每天 08:00PST
- 文件: `~/.openclaw/workspace/reminders/active.md` - 文件: `~/.openclaw/workspace/reminders/active.md`
**新系统**: 已合并到日历技能(`skills/calendar/`),使用 VTODO + todoman + CalDAV 同步
**功能**:
- 显示所有 pending 事项
- 按优先级分组(高/中/低)
- 显示剩余天数(今天/明天/X天后/逾期)
- 备注说明"为什么要做"
--- ---
@@ -100,6 +94,7 @@ _这份文件记录持续性项目和重要状态跨会话保留。_
- 日历数据: `~/.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
- 待办管理: todoman`todo` CLI
- 运行方式: `uv run`(依赖 `icalendar` 库) - 运行方式: `uv run`(依赖 `icalendar` 库)
**功能**: **功能**:
@@ -115,12 +110,11 @@ _这份文件记录持续性项目和重要状态跨会话保留。_
| 项目 | 位置 | | 项目 | 位置 |
|------|------| |------|------|
| 待办提醒 | `~/.openclaw/workspace/scripts/reminder_check.py` |
| 邮件处理器 | `~/.openclaw/workspace/scripts/email_processor/` | | 邮件处理器 | `~/.openclaw/workspace/scripts/email_processor/` |
| 待办列表 | `~/.openclaw/workspace/reminders/active.md` |
| 日历/待办 | `~/.openclaw/workspace/skills/calendar/` | | 日历/待办 | `~/.openclaw/workspace/skills/calendar/` |
| 日历数据 | `~/.openclaw/workspace/calendars/` | | 日历数据 | `~/.openclaw/workspace/calendars/` (home=事件, tasks=待办) |
| ~~待办提醒~~ | ~~`scripts/reminder_check.py`~~ → 已迁移到 `skills/calendar/` |
--- ---
_最后更新: 2026-03-18_ _最后更新: 2026-03-22_

View File

@@ -152,7 +152,7 @@ khal list today 7d
``` ```
**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、待办管理 (`todo add/list/complete/delete/check`) **支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、待办管理 (`todo add/list/complete/delete/check`)
**依赖**: himalaya邮件、vdirsyncerCalDAV 同步、khal查看日历 **依赖**: himalaya邮件、vdirsyncerCalDAV 同步、khal查看日历、todoman待办管理
**同步**: 发送/回复后自动 `vdirsyncer sync`,心跳也会定期同步 **同步**: 发送/回复后自动 `vdirsyncer sync`,心跳也会定期同步
**自动抄送**: mail@luyx.org用户别名自动加入所有邀请 **自动抄送**: mail@luyx.org用户别名自动加入所有邀请
**注意**: 发送日历邀请属于对外邮件,除 mail@luyx.org 外需先确认 **注意**: 发送日历邀请属于对外邮件,除 mail@luyx.org 外需先确认

View File

@@ -8,15 +8,52 @@
| `reminders/active.md` (markdown table) | `calendars/tasks/*.ics` (RFC 5545 VTODO) | | `reminders/active.md` (markdown table) | `calendars/tasks/*.ics` (RFC 5545 VTODO) |
| Local only, no sync | CalDAV sync to all devices via vdirsyncer | | Local only, no sync | CalDAV sync to all devices via vdirsyncer |
| No native reminders | VALARM triggers on phone/desktop | | No native reminders | VALARM triggers on phone/desktop |
| Cron reads markdown | Cron reads .ics files | | Cron reads markdown | Cron reads .ics via todoman |
| Custom Python parsing | todoman for robust RFC 5545 VTODO handling |
## Prerequisites ## Prerequisites
- [ ] `calendar.sh todo add --dry-run` works (tested in current session) - [ ] Install todoman (see below)
- [ ] Configure todoman (see below)
- [ ] `calendar.sh todo add --dry-run` works
- [ ] `~/.openclaw/workspace/calendars/tasks/` directory exists (auto-created by `todo add`) - [ ] `~/.openclaw/workspace/calendars/tasks/` directory exists (auto-created by `todo add`)
- [ ] vdirsyncer has a `cal/tasks` pair configured - [ ] vdirsyncer has a `cal/tasks` pair configured
- [ ] Live tests 8-12 from `TESTING.md` pass on the Linux machine - [ ] Live tests 8-12 from `TESTING.md` pass on the Linux machine
## Step 0: Install and configure todoman
### Install
```bash
# Debian/Ubuntu (the agent's Linux machine)
pip install todoman
# macOS (Homebrew)
brew install todoman
```
The CLI command is `todo`.
### Configure
Create `~/.config/todoman/config.py`:
```python
path = "~/.openclaw/workspace/calendars/tasks/"
date_format = "%Y-%m-%d"
time_format = "%H:%M"
default_due = 24
default_priority = 0
humanize = True
```
### Verify
```bash
# Should list todos (or show empty list without errors)
todo list
```
## Step 1: Migrate pending reminders ## Step 1: Migrate pending reminders
Two items are pending in `reminders/active.md`: Two items are pending in `reminders/active.md`:
@@ -37,6 +74,8 @@ $SKILL_DIR/scripts/calendar.sh todo add \
--description "询问iui报销相关事宜" --description "询问iui报销相关事宜"
``` ```
Note: `todo add` emails each item to `mail@luyx.org`. This is expected.
Verify: Verify:
```bash ```bash
$SKILL_DIR/scripts/calendar.sh todo list $SKILL_DIR/scripts/calendar.sh todo list
@@ -50,27 +89,29 @@ Ask the agent to set up a daily cron job via `openclaw cron`:
> `~/.openclaw/workspace/skills/calendar/scripts/calendar.sh todo check` > `~/.openclaw/workspace/skills/calendar/scripts/calendar.sh todo check`
> and emails the output to mail@luyx.org with subject "📋 今日待办清单" > and emails the output to mail@luyx.org with subject "📋 今日待办清单"
The old `reminder_check.py` cron in `crontab.txt` should be removed by the agent at the same time. The old `reminder_check.py` cron should be removed by the agent at the same time.
## Step 3: Verify ## Step 3: Verify
- [ ] `todo list` shows both migrated items - [ ] `todo list` (todoman directly) shows both migrated items
- [ ] `calendar.sh todo list` shows both items with Chinese priority labels
- [ ] `vdirsyncer sync` completes without errors - [ ] `vdirsyncer sync` completes without errors
- [ ] Todos appear on phone/CalDAV client - [ ] Todos appear on phone/CalDAV client
- [ ] Next morning's cron email arrives with the new format - [ ] Next morning's cron email arrives with the new format
## Step 4: Clean up (optional, after confidence period) ## Step 4: Clean up
After a few days of successful operation: After a few days of successful operation:
- `reminders/active.md` — can be archived or deleted ```bash
- `scripts/reminder_check.py` — can be removed # Remove old reminder system
- `reminders/` directory — can be removed rm scripts/reminder_check.py
- Old cron entry in `crontab.txt` — should already be gone rm -r reminders/
```
## Rollback ## Rollback
If something goes wrong, the old system is still intact: If something goes wrong, the old system is still intact until Step 4:
- `reminders/active.md` is unchanged - `reminders/active.md` is unchanged
- `scripts/reminder_check.py` still works - `scripts/reminder_check.py` still works
- Re-add the old cron entry to restore the previous behavior - Re-add the old cron entry to restore the previous behavior

View File

@@ -1,7 +1,7 @@
--- ---
name: calendar name: calendar
description: "Calendar invites and VTODO task management via CalDAV. Send/reply to invites, create/list/complete/delete todos. Syncs to Migadu CalDAV 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","todo"],"skills":["himalaya"]}}}
--- ---
# Calendar # Calendar
@@ -16,6 +16,7 @@ See `TESTING.md` for dry-run and live test steps, verification checklists, and t
- `himalaya` configured and working (see the `himalaya` skill) - `himalaya` configured and working (see the `himalaya` skill)
- `vdirsyncer` configured and syncing to `~/.openclaw/workspace/calendars/` - `vdirsyncer` configured and syncing to `~/.openclaw/workspace/calendars/`
- `todoman` (`todo`) for VTODO management (list, complete, delete)
- `khal` for reading calendar (optional but recommended) - `khal` for reading calendar (optional but recommended)
- Runs via `uv run` (dependencies managed in `pyproject.toml`) - Runs via `uv run` (dependencies managed in `pyproject.toml`)
@@ -245,11 +246,9 @@ Same as `todo list` but only NEEDS-ACTION items. Exits silently when no pending
6. Runs `vdirsyncer sync` to push changes to Migadu CalDAV 6. Runs `vdirsyncer sync` to push changes to Migadu CalDAV
**Managing todos:** **Managing todos:**
1. Creates a VTODO ICS file with `STATUS:NEEDS-ACTION`, `PRIORITY`, `DUE`, `VALARM` 1. `todo add`: Creates a VTODO ICS file (via `icalendar` library), saves to `calendars/tasks/`, emails to owner, syncs
2. Saves to `~/.openclaw/workspace/calendars/tasks/` 2. `todo list/complete/delete/check`: Delegates to `todoman` CLI for robust RFC 5545 VTODO parsing
3. Emails the VTODO to `mail@luyx.org` as a delivery copy 3. Runs `vdirsyncer sync` after mutations to push changes to Migadu CalDAV
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 and tasks 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.)

View File

@@ -386,50 +386,44 @@ def cmd_reply(args):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# VTODO: helpers # VTODO: todoman helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _load_todos(): def _run_todoman(*todo_args):
"""Load all VTODO items from TASKS_DIR. Returns list of (path, vtodo) tuples.""" """Run a todoman command and return its stdout."""
if not TASKS_DIR.is_dir(): cmd = ["todo"] + list(todo_args)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"Error: todoman failed: {result.stderr.strip()}", file=sys.stderr)
sys.exit(1)
return result.stdout
def _todoman_list_json(*extra_args):
"""Get todos as JSON from todoman --porcelain."""
cmd = ["todo", "--porcelain", "list"] + list(extra_args)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"Error: todoman failed: {result.stderr.strip()}", file=sys.stderr)
sys.exit(1)
if not result.stdout.strip():
return [] return []
todos = [] import json
for ics_path in TASKS_DIR.glob("*.ics"): return json.loads(result.stdout)
def _days_until(due_str):
"""Days from today until due date string. Negative means overdue."""
if not due_str:
return None
try:
due_date = datetime.fromisoformat(due_str).date()
except (ValueError, TypeError):
try: try:
cal = Calendar.from_ical(ics_path.read_bytes()) due_date = _parse_date(due_str)
for component in cal.walk(): except ValueError:
if component.name == "VTODO": return None
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 return (due_date - date.today()).days
@@ -449,42 +443,19 @@ def _urgency_label(days):
return f"🟢 {days} 天后" return f"🟢 {days} 天后"
def _find_todo_by_match(todos, match_str): def _find_todo_id(match_str):
"""Find a single todo by fuzzy match on SUMMARY. Exits on 0 or >1 matches.""" """Find a todoman ID by matching summary text. Exits on 0 or >1 matches."""
matches = [] todos = _todoman_list_json("--sort", "due")
for path, vtodo in todos: matches = [t for t in todos if match_str in (t.get("summary") or "")]
summary = str(vtodo.get("summary", ""))
if match_str in summary:
matches.append((path, vtodo))
if not matches: if not matches:
print(f"Error: No todo matching '{match_str}'", file=sys.stderr) print(f"Error: No todo matching '{match_str}'", file=sys.stderr)
sys.exit(1) sys.exit(1)
if len(matches) > 1: if len(matches) > 1:
print(f"Error: Multiple todos match '{match_str}':", file=sys.stderr) print(f"Error: Multiple todos match '{match_str}':", file=sys.stderr)
for _, vt in matches: for t in matches:
print(f" - {vt.get('summary')} (UID: {vt.get('uid')})", file=sys.stderr) print(f" - {t.get('summary')} (id: {t.get('id')})", 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) sys.exit(1)
return matches[0]["id"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -584,152 +555,116 @@ def cmd_todo_add(args):
print(f"Warning: Failed to email todo to {DEFAULT_OWNER_EMAIL}") print(f"Warning: Failed to email todo to {DEFAULT_OWNER_EMAIL}")
def cmd_todo_list(args): def _format_todo_digest(todos):
"""List todos, optionally including completed ones.""" """Format todos into the Chinese priority-grouped digest. Returns string or None."""
todos = _load_todos()
if not todos: if not todos:
print("No todos found.") return None
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() today_str = date.today().isoformat()
print(f"📋 待办事项 ({today_str})") lines = [f"📋 待办事项 ({today_str})", "=" * 50]
print("=" * 50)
# Group by priority # Group by priority
groups = {1: [], 5: [], 9: []} groups = {1: [], 5: [], 9: []}
for path, vt in todos: for t in todos:
prio = _get_priority_int(vt) prio = t.get("priority") or 0
# Bucket into nearest standard priority if 1 <= prio <= 3:
if prio <= 3: groups[1].append(t)
groups[1].append((path, vt)) elif 4 <= prio <= 7:
elif prio <= 7: groups[5].append(t)
groups[5].append((path, vt))
else: else:
groups[9].append((path, vt)) groups[9].append(t)
for prio, emoji, label in [(1, "🔴", "高优先级"), (5, "🟡", "中优先级"), (9, "🟢", "低优先级")]: for prio, emoji, label in [(1, "🔴", "高优先级"), (5, "🟡", "中优先级"), (9, "🟢", "低优先级")]:
items = groups[prio] items = groups[prio]
if not items: if not items:
continue continue
print(f"\n{emoji} {label}") lines.append(f"\n{emoji} {label}")
for _, vt in items: for t in items:
summary = str(vt.get("summary", "")) summary = t.get("summary") or ""
due = _get_due_date(vt) due = t.get("due")
days = _days_until(due) days = _days_until(due)
urgency = _urgency_label(days) urgency = _urgency_label(days)
status = str(vt.get("status", "")) desc = t.get("description") or ""
desc = str(vt.get("description", "")) is_completed = t.get("is_completed", False)
line = f"{summary} ({urgency})" if is_completed:
if status == "COMPLETED":
line = f" • ✅ {summary} (已完成)" line = f" • ✅ {summary} (已完成)"
else:
line = f"{summary} ({urgency})"
if desc: if desc:
line += f" | {desc}" line += f" | {desc}"
print(line) lines.append(line)
print("\n" + "=" * 50) lines.append("\n" + "=" * 50)
return "\n".join(lines)
def cmd_todo_list(args):
"""List todos via todoman, optionally including completed ones."""
extra = ["--sort", "priority"]
if args.all:
extra += ["--status", "ANY"]
todos = _todoman_list_json(*extra)
if not todos:
print("No pending todos." if not args.all else "No todos found.")
return
output = _format_todo_digest(todos)
if output:
print(output)
def cmd_todo_complete(args): def cmd_todo_complete(args):
"""Mark a todo as COMPLETED.""" """Mark a todo as done via todoman."""
todos = _load_todos() if args.uid:
path, vtodo = _resolve_todo(args, todos) # todoman uses numeric IDs, not UIDs — search by UID in summary fallback
todos = _todoman_list_json("--sort", "due")
match = [t for t in todos if args.uid in (t.get("uid") or "")]
if not match:
print(f"Error: No todo with UID '{args.uid}'", file=sys.stderr)
sys.exit(1)
todo_id = match[0]["id"]
elif args.match:
todo_id = _find_todo_id(args.match)
else:
print("Error: --uid or --match is required", file=sys.stderr)
sys.exit(1)
# Read, modify, rewrite _run_todoman("done", str(todo_id))
cal = Calendar.from_ical(path.read_bytes()) print(f"Completed todo #{todo_id}")
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() _sync_calendar()
def cmd_todo_delete(args): def cmd_todo_delete(args):
"""Delete a todo .ics file.""" """Delete a todo via todoman."""
todos = _load_todos() if args.uid:
path, vtodo = _resolve_todo(args, todos) todos = _todoman_list_json("--sort", "due")
match = [t for t in todos if args.uid in (t.get("uid") or "")]
if not match:
print(f"Error: No todo with UID '{args.uid}'", file=sys.stderr)
sys.exit(1)
todo_id = match[0]["id"]
elif args.match:
todo_id = _find_todo_id(args.match)
else:
print("Error: --uid or --match is required", file=sys.stderr)
sys.exit(1)
summary = str(vtodo.get("summary", "")) _run_todoman("delete", str(todo_id))
path.unlink() print(f"Deleted todo #{todo_id}")
print(f"Deleted: {summary}")
_sync_calendar() _sync_calendar()
def cmd_todo_check(args): def cmd_todo_check(args):
"""Daily digest of pending todos (for cron). Silent when empty.""" """Daily digest of pending todos (for cron). Silent when empty."""
todos = _load_todos() todos = _todoman_list_json("--sort", "priority")
# Filter to NEEDS-ACTION only if not todos:
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 return # silent exit
# Sort by priority then due date output = _format_todo_digest(todos)
def sort_key(item): if output:
_, vt = item print(output)
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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------