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.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user