From 383b33cc30ec22680421a1767c0fa5908d18c3ca Mon Sep 17 00:00:00 2001 From: Yanxin Lu Date: Sun, 22 Mar 2026 15:00:20 -0700 Subject: [PATCH] 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. --- skills/calendar/MIGRATION.md | 61 +++++- skills/calendar/SKILL.md | 11 +- skills/calendar/scripts/cal_tool.py | 295 +++++++++++----------------- 3 files changed, 171 insertions(+), 196 deletions(-) diff --git a/skills/calendar/MIGRATION.md b/skills/calendar/MIGRATION.md index cefbcd9..b093e4e 100644 --- a/skills/calendar/MIGRATION.md +++ b/skills/calendar/MIGRATION.md @@ -8,15 +8,52 @@ | `reminders/active.md` (markdown table) | `calendars/tasks/*.ics` (RFC 5545 VTODO) | | Local only, no sync | CalDAV sync to all devices via vdirsyncer | | 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 -- [ ] `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`) - [ ] vdirsyncer has a `cal/tasks` pair configured - [ ] 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 Two items are pending in `reminders/active.md`: @@ -37,6 +74,8 @@ $SKILL_DIR/scripts/calendar.sh todo add \ --description "询问iui报销相关事宜" ``` +Note: `todo add` emails each item to `mail@luyx.org`. This is expected. + Verify: ```bash $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` > 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 -- [ ] `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 - [ ] Todos appear on phone/CalDAV client - [ ] 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: -- `reminders/active.md` — can be archived or deleted -- `scripts/reminder_check.py` — can be removed -- `reminders/` directory — can be removed -- Old cron entry in `crontab.txt` — should already be gone +```bash +# Remove old reminder system +rm scripts/reminder_check.py +rm -r reminders/ +``` ## 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 - `scripts/reminder_check.py` still works - Re-add the old cron entry to restore the previous behavior diff --git a/skills/calendar/SKILL.md b/skills/calendar/SKILL.md index d97a39a..42a1fe8 100644 --- a/skills/calendar/SKILL.md +++ b/skills/calendar/SKILL.md @@ -1,7 +1,7 @@ --- 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." -metadata: {"clawdbot":{"emoji":"📅","requires":{"bins":["himalaya","vdirsyncer"],"skills":["himalaya"]}}} +metadata: {"clawdbot":{"emoji":"📅","requires":{"bins":["himalaya","vdirsyncer","todo"],"skills":["himalaya"]}}} --- # 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) - `vdirsyncer` configured and syncing to `~/.openclaw/workspace/calendars/` +- `todoman` (`todo`) for VTODO management (list, complete, delete) - `khal` for reading calendar (optional but recommended) - 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 **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 +1. `todo add`: Creates a VTODO ICS file (via `icalendar` library), saves to `calendars/tasks/`, emails to owner, syncs +2. `todo list/complete/delete/check`: Delegates to `todoman` CLI for robust RFC 5545 VTODO parsing +3. Runs `vdirsyncer sync` after mutations to push changes to Migadu CalDAV **CalDAV sync:** - Events and tasks sync to Migadu and appear on all connected devices (DAVx5, etc.) diff --git a/skills/calendar/scripts/cal_tool.py b/skills/calendar/scripts/cal_tool.py index 1479565..950b342 100644 --- a/skills/calendar/scripts/cal_tool.py +++ b/skills/calendar/scripts/cal_tool.py @@ -386,50 +386,44 @@ def cmd_reply(args): # --------------------------------------------------------------------------- -# VTODO: helpers +# VTODO: todoman helpers # --------------------------------------------------------------------------- -def _load_todos(): - """Load all VTODO items from TASKS_DIR. Returns list of (path, vtodo) tuples.""" - if not TASKS_DIR.is_dir(): +def _run_todoman(*todo_args): + """Run a todoman command and return its stdout.""" + 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 [] - todos = [] - for ics_path in TASKS_DIR.glob("*.ics"): + import json + 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: - 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 + due_date = _parse_date(due_str) + except ValueError: + return None return (due_date - date.today()).days @@ -449,42 +443,19 @@ def _urgency_label(days): 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)) +def _find_todo_id(match_str): + """Find a todoman ID by matching summary text. Exits on 0 or >1 matches.""" + todos = _todoman_list_json("--sort", "due") + matches = [t for t in todos if match_str in (t.get("summary") or "")] 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) + for t in matches: + print(f" - {t.get('summary')} (id: {t.get('id')})", file=sys.stderr) 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}") -def cmd_todo_list(args): - """List todos, optionally including completed ones.""" - todos = _load_todos() +def _format_todo_digest(todos): + """Format todos into the Chinese priority-grouped digest. Returns string or None.""" if not todos: - print("No todos found.") - return + return None - # 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) + lines = [f"📋 待办事项 ({today_str})", "=" * 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)) + for t in todos: + prio = t.get("priority") or 0 + if 1 <= prio <= 3: + groups[1].append(t) + elif 4 <= prio <= 7: + groups[5].append(t) else: - groups[9].append((path, vt)) + groups[9].append(t) 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) + lines.append(f"\n{emoji} {label}:") + for t in items: + summary = t.get("summary") or "" + due = t.get("due") days = _days_until(due) urgency = _urgency_label(days) - status = str(vt.get("status", "")) - desc = str(vt.get("description", "")) + desc = t.get("description") or "" + is_completed = t.get("is_completed", False) - line = f" • {summary} ({urgency})" - if status == "COMPLETED": + if is_completed: line = f" • ✅ {summary} (已完成)" + else: + line = f" • {summary} ({urgency})" if 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): - """Mark a todo as COMPLETED.""" - todos = _load_todos() - path, vtodo = _resolve_todo(args, todos) + """Mark a todo as done via todoman.""" + if args.uid: + # 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 - 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}") + _run_todoman("done", str(todo_id)) + print(f"Completed todo #{todo_id}") _sync_calendar() def cmd_todo_delete(args): - """Delete a todo .ics file.""" - todos = _load_todos() - path, vtodo = _resolve_todo(args, todos) + """Delete a todo via todoman.""" + if args.uid: + 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", "")) - path.unlink() - print(f"Deleted: {summary}") + _run_todoman("delete", str(todo_id)) + print(f"Deleted todo #{todo_id}") _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: + todos = _todoman_list_json("--sort", "priority") + if not todos: 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) + output = _format_todo_digest(todos) + if output: + print(output) # ---------------------------------------------------------------------------