#!/usr/bin/env python3 """ 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 himalaya CLI for email delivery. Syncs to local CalDAV via vdirsyncer. Subcommands: python calendar.py send [options] # create and send an invite (supports --rrule) python calendar.py reply [options] # accept/decline/tentative python calendar.py event list [options] # list/search calendar events python calendar.py event delete [options] # delete an event by UID or summary python calendar.py todo add [options] # create a VTODO task python calendar.py todo list [options] # list pending tasks python calendar.py todo edit [options] # edit a task's fields 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 shutil import subprocess import sys import uuid from datetime import date, datetime, timedelta, timezone from pathlib import Path from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vRecur, vText # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- DEFAULT_TIMEZONE = "America/Los_Angeles" DEFAULT_FROM = "youlu@luyanxin.com" CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home" 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: "低"} # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _sync_calendar(): """Sync local calendar to CalDAV server via vdirsyncer.""" try: subprocess.run( ["vdirsyncer", "sync"], capture_output=True, text=True, check=True, ) print("Synced to CalDAV server") except (subprocess.CalledProcessError, FileNotFoundError): print("Warning: CalDAV sync failed (will retry on next heartbeat)") def _send_email(email_str, account=None): """Send a raw MIME email via himalaya message send (stdin).""" cmd = ["himalaya"] if account: cmd += ["--account", account] cmd += ["message", "send"] subprocess.run(cmd, input=email_str, text=True, check=True) def _build_calendar_email(from_addr, to_addr, subject, body, ics_bytes, method="REQUEST"): """Build a MIME email with a text/calendar attachment.""" msg = MIMEMultipart('mixed') msg['From'] = from_addr msg['To'] = to_addr msg['Subject'] = subject msg.attach(MIMEText(body, 'plain', 'utf-8')) ics_part = MIMEBase('text', 'calendar', method=method, charset='utf-8') ics_part.set_payload(ics_bytes.decode('utf-8')) ics_part.add_header('Content-Disposition', 'attachment; filename="invite.ics"') msg.attach(ics_part) return msg.as_string() def _strip_method(ics_bytes): """Remove METHOD property from ICS for CalDAV storage. CalDAV servers reject METHOD (it's an iTIP/email concept, not a storage one). """ cal = Calendar.from_ical(ics_bytes) if "method" in cal: del cal["method"] return cal.to_ical() def _parse_iso_datetime(dt_str): """Parse ISO 8601 datetime string to a naive datetime object.""" try: dt = datetime.fromisoformat(dt_str) # Strip tzinfo if present — timezone is handled via --timezone / TZID param return dt.replace(tzinfo=None) except ValueError: 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() # iCalendar day abbreviation -> Python weekday (0=Mon, 6=Sun) _ICAL_DAY_TO_WEEKDAY = { "MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6, } def _validate_rrule_dtstart(rrule_dict, dtstart): """Validate that DTSTART day-of-week matches BYDAY when FREQ=WEEKLY. RFC 5545: 'The DTSTART property value SHOULD be synchronized with the recurrence rule. The recurrence set generated with a DTSTART property value not synchronized with the recurrence rule is undefined.' """ freq = rrule_dict.get("FREQ") if not freq: return # Normalize: icalendar may return freq as list or string if isinstance(freq, list): freq = freq[0] if str(freq).upper() != "WEEKLY": return byday = rrule_dict.get("BYDAY") if not byday: return if not isinstance(byday, list): byday = [byday] # Only validate alignment when there's a single BYDAY value if len(byday) != 1: return day_str = str(byday[0]).upper() expected_weekday = _ICAL_DAY_TO_WEEKDAY.get(day_str) if expected_weekday is None: return if dtstart.weekday() != expected_weekday: actual_day = [k for k, v in _ICAL_DAY_TO_WEEKDAY.items() if v == dtstart.weekday()][0] print( f"Error: DTSTART falls on {actual_day} but RRULE says BYDAY={day_str}. " f"RFC 5545 requires these to match for FREQ=WEEKLY. " f"Change --start to a date that falls on {day_str}.", file=sys.stderr, ) sys.exit(1) # --------------------------------------------------------------------------- # Send invite # --------------------------------------------------------------------------- def cmd_send(args): """Create and send a calendar invite.""" start = _parse_iso_datetime(args.start) end = _parse_iso_datetime(args.end) uid = args.uid or f"{uuid.uuid4()}@openclaw" organizer_name = args.organizer or args.sender # Build ICS cal = Calendar() cal.add("prodid", PRODID) cal.add("version", "2.0") cal.add("calscale", "GREGORIAN") cal.add("method", "REQUEST") event = Event() event.add("uid", uid) event.add("dtstamp", datetime.now(timezone.utc)) event.add("dtstart", start, parameters={"TZID": args.timezone}) event.add("dtend", end, parameters={"TZID": args.timezone}) event.add("summary", args.summary) event.add("status", "CONFIRMED") event.add("sequence", 0) organizer = vCalAddress(f"mailto:{args.sender}") organizer.params["CN"] = vText(organizer_name) event.add("organizer", organizer) if args.location: event.add("location", args.location) if args.description: event.add("description", args.description) recipients = [addr.strip() for addr in args.to.split(",")] for addr in recipients: event.add("attendee", f"mailto:{addr}", parameters={ "ROLE": "REQ-PARTICIPANT", "RSVP": "TRUE", }) # Recurrence rule if args.rrule: rrule = vRecur.from_ical(args.rrule) _validate_rrule_dtstart(rrule, start) event.add("rrule", rrule) # Reminder alarm alarm_trigger = timedelta(days=-1) # default 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])) alarm = Alarm() alarm.add("action", "DISPLAY") alarm.add("description", f"Reminder: {args.summary}") alarm.add("trigger", alarm_trigger) event.add_component(alarm) cal.add_component(event) ics_bytes = cal.to_ical() # Build plain text body body = f"You're invited to: {args.summary}\n\nWhen: {args.start} - {args.end} ({args.timezone})" if args.location: body += f"\nWhere: {args.location}" if args.description: body += f"\n\n{args.description}" # Build MIME email email_str = _build_calendar_email(args.sender, ", ".join(recipients), args.subject, body, ics_bytes, method="REQUEST") if args.dry_run: print("=== ICS Content ===") print(ics_bytes.decode()) print("=== Email Message ===") print(email_str) return # Send email via himalaya message send (stdin) _send_email(email_str, args.account) print(f"Calendar invite sent to: {args.to}") # Save to local calendar (without METHOD for CalDAV compatibility) if CALENDAR_DIR.is_dir(): dest = CALENDAR_DIR / f"{uid}.ics" dest.write_bytes(_strip_method(ics_bytes)) print(f"Saved to local calendar: {dest}") _sync_calendar() # --------------------------------------------------------------------------- # Reply to invite # --------------------------------------------------------------------------- PARTSTAT_MAP = { "accept": "ACCEPTED", "accepted": "ACCEPTED", "decline": "DECLINED", "declined": "DECLINED", "tentative": "TENTATIVE", } SUBJECT_PREFIX = { "ACCEPTED": "Accepted", "DECLINED": "Declined", "TENTATIVE": "Tentative", } def _extract_ics_from_email(envelope_id, folder, account): """Download attachments from an email and find the .ics file.""" download_dir = Path(f"/tmp/openclaw-ics-extract-{envelope_id}") download_dir.mkdir(exist_ok=True) cmd = ["himalaya"] if account: cmd += ["--account", account] cmd += ["attachment", "download", "--folder", folder, str(envelope_id), "--dir", str(download_dir)] try: subprocess.run(cmd, capture_output=True, text=True, check=True) except subprocess.CalledProcessError: pass # some emails have no attachments ics_files = list(download_dir.glob("*.ics")) if not ics_files: print(f"Error: No .ics attachment found in envelope {envelope_id}", file=sys.stderr) shutil.rmtree(download_dir, ignore_errors=True) sys.exit(1) return ics_files[0], download_dir def cmd_reply(args): """Accept, decline, or tentatively accept a calendar invite.""" partstat = PARTSTAT_MAP.get(args.action.lower()) if not partstat: print(f"Error: --action must be accept, decline, or tentative", file=sys.stderr) sys.exit(1) # Get the ICS file cleanup_dir = None if args.envelope_id: ics_path, cleanup_dir = _extract_ics_from_email(args.envelope_id, args.folder, args.account) elif args.ics_file: ics_path = Path(args.ics_file) if not ics_path.is_file(): print(f"Error: ICS file not found: {ics_path}", file=sys.stderr) sys.exit(1) else: print("Error: --envelope-id or --ics-file is required", file=sys.stderr) sys.exit(1) # Parse original ICS original_cal = Calendar.from_ical(ics_path.read_bytes()) # Find the VEVENT original_event = None for component in original_cal.walk(): if component.name == "VEVENT": original_event = component break if not original_event: print("Error: No VEVENT found in ICS file", file=sys.stderr) sys.exit(1) # Extract fields from original uid = str(original_event.get("uid", "")) summary = str(original_event.get("summary", "")) organizer = original_event.get("organizer") if not organizer: print("Error: No ORGANIZER found in ICS", file=sys.stderr) sys.exit(1) organizer_email = str(organizer).replace("mailto:", "").replace("MAILTO:", "") # Build reply calendar reply_cal = Calendar() reply_cal.add("prodid", PRODID) reply_cal.add("version", "2.0") reply_cal.add("calscale", "GREGORIAN") reply_cal.add("method", "REPLY") reply_event = Event() reply_event.add("uid", uid) reply_event.add("dtstamp", datetime.now(timezone.utc)) # Copy timing from original if original_event.get("dtstart"): reply_event["dtstart"] = original_event["dtstart"] if original_event.get("dtend"): reply_event["dtend"] = original_event["dtend"] reply_event.add("summary", summary) reply_event["organizer"] = original_event["organizer"] reply_event.add("attendee", f"mailto:{args.sender}", parameters={ "PARTSTAT": partstat, "RSVP": "FALSE", }) if original_event.get("sequence"): reply_event.add("sequence", original_event.get("sequence")) reply_cal.add_component(reply_event) reply_ics_bytes = reply_cal.to_ical() # Build email prefix = SUBJECT_PREFIX[partstat] subject = f"{prefix}: {summary}" body = f"{prefix}: {summary}" if args.comment: body += f"\n\n{args.comment}" email_str = _build_calendar_email(args.sender, organizer_email, subject, body, reply_ics_bytes, method="REPLY") if args.dry_run: print("=== Original Event ===") print(f"Summary: {summary}") print(f"Organizer: {organizer_email}") print(f"Action: {partstat}") print() print("=== Reply ICS ===") print(reply_ics_bytes.decode()) print("=== Email Message ===") print(email_str) if cleanup_dir: shutil.rmtree(cleanup_dir, ignore_errors=True) return # Send reply _send_email(email_str, args.account) print(f"Calendar invite {partstat.lower()}: {summary} (replied to {organizer_email})") # Save to / remove from local calendar if CALENDAR_DIR.is_dir(): dest = CALENDAR_DIR / f"{uid}.ics" if partstat in ("ACCEPTED", "TENTATIVE"): # Save the original event to local calendar (without METHOD for CalDAV) dest.write_bytes(_strip_method(ics_path.read_bytes())) print(f"Saved to local calendar: {dest}") elif partstat == "DECLINED" and dest.is_file(): dest.unlink() print("Removed from local calendar") _sync_calendar() # Cleanup if cleanup_dir: shutil.rmtree(cleanup_dir, ignore_errors=True) # --------------------------------------------------------------------------- # VTODO: todoman helpers # --------------------------------------------------------------------------- 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 [] import json return json.loads(result.stdout) def _days_until(due_val): """Days from today until due date (string or Unix timestamp). Negative means overdue.""" if not due_val: return None # Unix timestamp (int/float) from todoman --porcelain if isinstance(due_val, (int, float)): try: due_date = datetime.fromtimestamp(due_val, tz=timezone.utc).date() except (ValueError, TypeError, OSError): return None return (due_date - date.today()).days # String formats (ISO or YYYY-MM-DD) try: due_date = datetime.fromisoformat(due_val).date() except (ValueError, TypeError): try: due_date = _parse_date(due_val) except ValueError: return None return (due_date - date.today()).days def _urgency_label(days): """Urgency label with emoji.""" 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} 天后" # --------------------------------------------------------------------------- # 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") 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() prio_label = PRIORITY_LABELS.get(priority, "中") if args.dry_run: print("=== ICS Content ===") print(ics_bytes.decode()) return # Save to TASKS_DIR dest = TASKS_DIR / f"{uid}.ics" dest.write_bytes(ics_bytes) print(f"Todo created: {args.summary} (due: {due_date}, priority: {prio_label})") print(f"Saved to: {dest}") # Sync _sync_calendar() def _format_todo_digest(todos): """Format todos into the Chinese priority-grouped digest. Returns string or None.""" if not todos: return None today_str = date.today().isoformat() lines = [f"📋 待办事项 ({today_str})", "=" * 50] # Group by priority groups = {1: [], 5: [], 9: []} 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(t) for prio, emoji, label in [(1, "🔴", "高优先级"), (5, "🟡", "中优先级"), (9, "🟢", "低优先级")]: items = groups[prio] if not items: continue 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) desc = t.get("description") or "" is_completed = t.get("completed", False) if is_completed: line = f" • ✅ {summary} (已完成)" else: line = f" • {summary} ({urgency})" if desc: line += f" | {desc}" lines.append(line) 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 done via todoman.""" todo_id, matched = _find_todo(uid=args.uid, match=args.match) _run_todoman("done", str(todo_id)) print(f"Completed todo: {matched.get('summary')}") _sync_calendar() def _find_todo(uid="", match=""): """Find a single todo by UID or summary match. Returns (todoman_id, todo_dict).""" todos = _todoman_list_json("--sort", "due") if uid: matches = [t for t in todos if uid in (t.get("uid") or "")] if not matches: print(f"Error: No todo with UID '{uid}'", file=sys.stderr) sys.exit(1) elif match: matches = [t for t in todos if match in (t.get("summary") or "")] if not matches: print(f"Error: No todo matching '{match}'", file=sys.stderr) sys.exit(1) if len(matches) > 1: print(f"Error: Multiple todos match '{match}':", file=sys.stderr) for t in matches: print(f" - {t.get('summary')} (uid: {t.get('uid')})", file=sys.stderr) sys.exit(1) else: print("Error: --uid or --match is required", file=sys.stderr) sys.exit(1) return matches[0]["id"], matches[0] def cmd_todo_edit(args): """Edit a todo's fields via todoman.""" todo_id, matched = _find_todo(uid=args.uid, match=args.match) # Build todoman edit command args todo_args = [] changes = [] if args.due: todo_args += ["--due", args.due] changes.append(f"due -> {args.due}") if args.priority: todo_args += ["--priority", args.priority] prio_label = PRIORITY_LABELS.get(PRIORITY_MAP.get(args.priority, 5), "中") changes.append(f"priority -> {prio_label}") if not changes: print("Nothing to change. Specify at least one of --due, --priority.") return _run_todoman("edit", str(todo_id), *todo_args) print(f"Updated todo: {matched.get('summary')}") for c in changes: print(f" {c}") _sync_calendar() def cmd_todo_delete(args): """Delete a todo via todoman.""" todo_id, matched = _find_todo(uid=args.uid, match=args.match) _run_todoman("delete", "--yes", str(todo_id)) print(f"Deleted todo: {matched.get('summary')}") _sync_calendar() def cmd_todo_check(args): """Daily digest of pending todos (for cron). Silent when empty.""" todos = _todoman_list_json("--sort", "priority") if not todos: return # silent exit output = _format_todo_digest(todos) if output: print(output) # --------------------------------------------------------------------------- # Event management # --------------------------------------------------------------------------- def cmd_event_list(args): """List calendar events via khal.""" _sync_calendar() if args.search: cmd = ["khal", "search"] if args.format: cmd += ["--format", args.format] cmd.append(args.search) else: cmd = ["khal", "list"] if args.format: cmd += ["--format", args.format] cmd += [args.range_start, args.range_end] try: result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: # khal search returns 1 when no results found — not a real error if args.search and not result.stderr.strip(): print(f"No events matching '{args.search}'.") return print(f"Error: khal failed: {result.stderr.strip()}", file=sys.stderr) sys.exit(1) output = result.stdout.rstrip() print(output if output else "No events found.") except FileNotFoundError: print("Error: khal is not installed", file=sys.stderr) sys.exit(1) def cmd_event_delete(args): """Delete a calendar event or cancel a single occurrence of a recurring event. For recurring events: --date YYYY-MM-DD Cancel one occurrence by adding EXDATE (keeps the series) --all Delete the entire series (required safety flag) Without --date or --all on a recurring event, the command refuses to act. """ if not args.uid and not args.match: print("Error: --uid or --match is required", file=sys.stderr) sys.exit(1) if not CALENDAR_DIR.is_dir(): print(f"Error: Calendar directory not found: {CALENDAR_DIR}", file=sys.stderr) sys.exit(1) ics_files = list(CALENDAR_DIR.glob("*.ics")) if not ics_files: print("No events found in calendar.", file=sys.stderr) sys.exit(1) # Find matching event(s) matches = [] for ics_path in ics_files: try: cal = Calendar.from_ical(ics_path.read_bytes()) except Exception: continue for component in cal.walk(): if component.name == "VEVENT": uid = str(component.get("uid", "")) summary = str(component.get("summary", "")) if args.uid and args.uid in uid: matches.append((ics_path, uid, summary, component)) elif args.match and args.match in summary: matches.append((ics_path, uid, summary, component)) if not matches: target = args.uid or args.match print(f"Error: No event matching '{target}'", file=sys.stderr) sys.exit(1) if len(matches) > 1: print(f"Error: Multiple events match:", file=sys.stderr) for _, uid, summary, _ in matches: print(f" - {summary} (uid: {uid})", file=sys.stderr) sys.exit(1) ics_path, uid, summary, vevent = matches[0] has_rrule = vevent.get("rrule") is not None if has_rrule and not args.date and not args.all: print( f"Error: '{summary}' is a recurring event. Use one of:\n" f" --date YYYY-MM-DD Cancel a single occurrence\n" f" --all Delete the entire series", file=sys.stderr, ) sys.exit(1) if args.date and has_rrule: # Add EXDATE to cancel a single occurrence _add_exdate(ics_path, vevent, args.date, summary, uid) else: # Delete the entire event (single event, or recurring with --all) ics_path.unlink() if has_rrule: print(f"Deleted recurring event series: {summary} (uid: {uid})") else: print(f"Deleted event: {summary} (uid: {uid})") _sync_calendar() def _add_exdate(ics_path, vevent, date_str, summary, uid): """Add an EXDATE to a recurring event to cancel a single occurrence.""" exclude_date = _parse_date(date_str) # Verify the date is a valid occurrence (matches the RRULE pattern) dtstart = vevent.get("dtstart").dt rrule = vevent.get("rrule") # Check if dtstart is a datetime or date if isinstance(dtstart, datetime): start_date = dtstart.date() if hasattr(dtstart, 'date') else dtstart else: start_date = dtstart if exclude_date < start_date: print(f"Error: --date {date_str} is before the event start ({start_date})", file=sys.stderr) sys.exit(1) # Re-read and modify the ICS file to add EXDATE cal = Calendar.from_ical(ics_path.read_bytes()) for component in cal.walk(): if component.name == "VEVENT" and str(component.get("uid", "")) == uid: # EXDATE value type must match DTSTART value type if isinstance(dtstart, datetime): # Use a datetime with the same time as DTSTART exclude_dt = datetime.combine(exclude_date, dtstart.time()) component.add("exdate", [exclude_dt], parameters={"TZID": vevent.get("dtstart").params.get("TZID", DEFAULT_TIMEZONE)}) else: component.add("exdate", [exclude_date]) break ics_path.write_bytes(cal.to_ical()) print(f"Cancelled {summary} on {date_str} (added EXDATE, series continues)") print(f"Updated: {ics_path}") # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser(description="Calendar and todo tool") subparsers = parser.add_subparsers(dest="command", required=True) # --- send --- send_p = subparsers.add_parser("send", help="Send a calendar invite") send_p.add_argument("--from", dest="sender", default=DEFAULT_FROM, help="Sender email") send_p.add_argument("--to", required=True, help="Recipient(s), comma-separated") send_p.add_argument("--subject", required=True, help="Email subject") send_p.add_argument("--summary", required=True, help="Event title") send_p.add_argument("--start", required=True, help="Start time (ISO 8601)") send_p.add_argument("--end", required=True, help="End time (ISO 8601)") send_p.add_argument("--timezone", default=DEFAULT_TIMEZONE, help="IANA timezone") send_p.add_argument("--location", default="", help="Event location") send_p.add_argument("--description", default="", help="Event description") send_p.add_argument("--organizer", default="", help="Organizer display name") send_p.add_argument("--rrule", default="", help="RRULE string (e.g. FREQ=WEEKLY;COUNT=13;BYDAY=TU)") send_p.add_argument("--uid", default="", help="Custom event UID") send_p.add_argument("--account", default="", help="Himalaya account") send_p.add_argument("--alarm", default="1d", help="Reminder trigger (e.g. 1d, 2h, 30m)") send_p.add_argument("--dry-run", action="store_true", help="Preview without sending") # --- reply --- reply_p = subparsers.add_parser("reply", help="Reply to a calendar invite") reply_p.add_argument("--from", dest="sender", default=DEFAULT_FROM, help="Your email") reply_p.add_argument("--action", required=True, help="accept, decline, or tentative") reply_p.add_argument("--envelope-id", default="", help="Himalaya envelope ID") reply_p.add_argument("--ics-file", default="", help="Path to .ics file") reply_p.add_argument("--account", default="", help="Himalaya account") reply_p.add_argument("--folder", default="INBOX", help="Himalaya folder") 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") # --- event --- event_p = subparsers.add_parser("event", help="Manage calendar events") event_sub = event_p.add_subparsers(dest="event_command", required=True) # event list elist_p = event_sub.add_parser("list", help="List events (via khal)") elist_p.add_argument("--search", default="", help="Search events by text") elist_p.add_argument("--range-start", default="today", help="Start of range (default: today)") elist_p.add_argument("--range-end", default="90d", help="End of range (default: 90d)") elist_p.add_argument("--format", default="", help="khal format string (e.g. '{uid} {title}')") # event delete edel_p = event_sub.add_parser("delete", help="Delete an event or cancel one occurrence") edel_p.add_argument("--uid", default="", help="Event UID") edel_p.add_argument("--match", default="", help="Match on summary text") edel_p.add_argument("--date", default="", help="Cancel single occurrence on this date (YYYY-MM-DD, for recurring events)") edel_p.add_argument("--all", action="store_true", help="Delete entire recurring series (safety flag)") # --- 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 edit edit_p = todo_sub.add_parser("edit", help="Edit a todo's fields") edit_p.add_argument("--uid", default="", help="Todo UID") edit_p.add_argument("--match", default="", help="Fuzzy match on summary") edit_p.add_argument("--due", default="", help="New due date (YYYY-MM-DD)") edit_p.add_argument("--priority", default="", choices=["", "high", "medium", "low"], help="New priority") # 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() if args.command == "send": cmd_send(args) elif args.command == "reply": cmd_reply(args) elif args.command == "event": if args.event_command == "list": cmd_event_list(args) elif args.event_command == "delete": cmd_event_delete(args) elif args.command == "todo": 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 == "edit": cmd_todo_edit(args) elif args.todo_command == "delete": cmd_todo_delete(args) elif args.todo_command == "check": cmd_todo_check(args) if __name__ == "__main__": main()