calendar: dedup todo lookups, robust khal search, cleanup improvements

- Replace _find_todo_id and inline UID lookups with _find_todo in
  complete/delete/edit (removes ~40 lines of duplicated logic)
- Handle khal search returning exit code 1 for no results
- Sync calendar before event list for fresh data
- Use shutil.rmtree for temp dir cleanup
- Use datetime.fromisoformat instead of manual format strings
This commit is contained in:
Yanxin Lu
2026-03-25 09:47:20 -07:00
parent 371209cf35
commit d2bc01f16c

View File

@@ -19,6 +19,7 @@ Subcommands:
""" """
import argparse import argparse
import shutil
import subprocess import subprocess
import sys import sys
import uuid import uuid
@@ -98,14 +99,13 @@ def _strip_method(ics_bytes):
def _parse_iso_datetime(dt_str): def _parse_iso_datetime(dt_str):
"""Parse ISO 8601 datetime string to a datetime object.""" """Parse ISO 8601 datetime string to a naive datetime object."""
# Handle both 2026-03-20T14:00:00 and 2026-03-20T14:00 try:
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"): dt = datetime.fromisoformat(dt_str)
try: # Strip tzinfo if present — timezone is handled via --timezone / TZID param
return datetime.strptime(dt_str, fmt) return dt.replace(tzinfo=None)
except ValueError: except ValueError:
continue raise ValueError(f"Cannot parse datetime: {dt_str}")
raise ValueError(f"Cannot parse datetime: {dt_str}")
def _parse_date(date_str): def _parse_date(date_str):
@@ -286,10 +286,7 @@ def _extract_ics_from_email(envelope_id, folder, account):
ics_files = list(download_dir.glob("*.ics")) ics_files = list(download_dir.glob("*.ics"))
if not ics_files: if not ics_files:
print(f"Error: No .ics attachment found in envelope {envelope_id}", file=sys.stderr) print(f"Error: No .ics attachment found in envelope {envelope_id}", file=sys.stderr)
# Cleanup shutil.rmtree(download_dir, ignore_errors=True)
for f in download_dir.iterdir():
f.unlink()
download_dir.rmdir()
sys.exit(1) sys.exit(1)
return ics_files[0], download_dir return ics_files[0], download_dir
@@ -391,9 +388,7 @@ def cmd_reply(args):
print("=== Email Message ===") print("=== Email Message ===")
print(email_str) print(email_str)
if cleanup_dir: if cleanup_dir:
for f in cleanup_dir.iterdir(): shutil.rmtree(cleanup_dir, ignore_errors=True)
f.unlink()
cleanup_dir.rmdir()
return return
# Send reply # Send reply
@@ -414,9 +409,7 @@ def cmd_reply(args):
# Cleanup # Cleanup
if cleanup_dir: if cleanup_dir:
for f in cleanup_dir.iterdir(): shutil.rmtree(cleanup_dir, ignore_errors=True)
f.unlink()
cleanup_dir.rmdir()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -485,19 +478,6 @@ def _urgency_label(days):
return f"🟢 {days} 天后" return f"🟢 {days} 天后"
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 t in matches:
print(f" - {t.get('summary')} (id: {t.get('id')})", file=sys.stderr)
sys.exit(1)
return matches[0]["id"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -638,22 +618,9 @@ def cmd_todo_list(args):
def cmd_todo_complete(args): def cmd_todo_complete(args):
"""Mark a todo as done via todoman.""" """Mark a todo as done via todoman."""
if args.uid: todo_id, matched = _find_todo(uid=args.uid, match=args.match)
# 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)
_run_todoman("done", str(todo_id)) _run_todoman("done", str(todo_id))
print(f"Completed todo #{todo_id}") print(f"Completed todo: {matched.get('summary')}")
_sync_calendar() _sync_calendar()
@@ -710,21 +677,9 @@ def cmd_todo_edit(args):
def cmd_todo_delete(args): def cmd_todo_delete(args):
"""Delete a todo via todoman.""" """Delete a todo via todoman."""
if args.uid: todo_id, matched = _find_todo(uid=args.uid, match=args.match)
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)
_run_todoman("delete", "--yes", str(todo_id)) _run_todoman("delete", "--yes", str(todo_id))
print(f"Deleted todo #{todo_id}") print(f"Deleted todo: {matched.get('summary')}")
_sync_calendar() _sync_calendar()
@@ -746,6 +701,7 @@ def cmd_todo_check(args):
def cmd_event_list(args): def cmd_event_list(args):
"""List calendar events via khal.""" """List calendar events via khal."""
_sync_calendar()
if args.search: if args.search:
cmd = ["khal", "search"] cmd = ["khal", "search"]
if args.format: if args.format:
@@ -757,11 +713,16 @@ def cmd_event_list(args):
cmd += ["--format", args.format] cmd += ["--format", args.format]
cmd += [args.range_start, args.range_end] cmd += [args.range_start, args.range_end]
try: try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True) result = subprocess.run(cmd, capture_output=True, text=True)
print(result.stdout.rstrip()) if result.returncode != 0:
except subprocess.CalledProcessError as e: # khal search returns 1 when no results found — not a real error
print(f"Error: khal failed: {e.stderr.strip()}", file=sys.stderr) if args.search and not result.stderr.strip():
sys.exit(1) 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: except FileNotFoundError:
print("Error: khal is not installed", file=sys.stderr) print("Error: khal is not installed", file=sys.stderr)
sys.exit(1) sys.exit(1)