2026-01-15 18:03:19 -08:00
|
|
|
#!/usr/bin/env -S uv run --script
|
|
|
|
|
# /// script
|
|
|
|
|
# requires-python = ">=3.12"
|
2026-03-24 08:11:46 -07:00
|
|
|
# dependencies = ["httpx>=0.28.1", "rich>=14.0.0"]
|
2026-01-15 18:03:19 -08:00
|
|
|
# ///
|
|
|
|
|
#MISE description="List Blumeops tasks from Todoist sorted by priority"
|
|
|
|
|
"""Fetch and display Blumeops tasks from Todoist, sorted by priority.
|
|
|
|
|
|
|
|
|
|
This script is specific to Erich Blume's personal development workflow and
|
|
|
|
|
is not intended for general use. It requires:
|
|
|
|
|
|
|
|
|
|
- A 1Password CLI (`op`) configured with access to the author's vault
|
|
|
|
|
- A Todoist account with a project named "Blumeops"
|
|
|
|
|
|
|
|
|
|
The script fetches tasks and displays them sorted by a custom priority order:
|
|
|
|
|
p1 (urgent), p2 (high), p4 (normal/default), p3 (backlog). The p3-last ordering
|
|
|
|
|
reflects a deliberate choice to treat p3 as "backlog" rather than moderate
|
|
|
|
|
priority.
|
|
|
|
|
|
|
|
|
|
Usage: mise run blumeops-tasks
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
2026-02-08 10:38:44 -08:00
|
|
|
from datetime import date
|
2026-01-15 18:03:19 -08:00
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
from rich.console import Console
|
|
|
|
|
from rich.text import Text
|
|
|
|
|
|
2026-02-11 14:33:37 -08:00
|
|
|
TODOIST_API_BASE = "https://api.todoist.com/api/v1"
|
2026-01-15 18:03:19 -08:00
|
|
|
PROJECT_NAME = "Blumeops"
|
|
|
|
|
|
|
|
|
|
# Priority mapping: Todoist API uses 1=normal(p4), 2=moderate(p3), 3=high(p2), 4=urgent(p1)
|
|
|
|
|
# User wants order: p1, p2, p4, p3 (p3 is backlog, goes last)
|
|
|
|
|
PRIORITY_LABELS = {4: "p1", 3: "p2", 1: "p4", 2: "p3"}
|
|
|
|
|
PRIORITY_SORT_ORDER = {4: 1, 3: 2, 1: 3, 2: 4} # Lower = earlier
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_todoist_token() -> str:
|
|
|
|
|
"""Retrieve Todoist API token from 1Password."""
|
|
|
|
|
result = subprocess.run(
|
2026-02-11 14:33:37 -08:00
|
|
|
["op", "read", "op://vg6xf6vvfmoh5hqjjhlhbeoaie/c53h3xnmswhvexa5mntoyvhgpm/credential"],
|
2026-01-15 18:03:19 -08:00
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
)
|
|
|
|
|
if result.returncode != 0:
|
|
|
|
|
raise RuntimeError(f"Failed to get Todoist token from 1Password: {result.stderr}")
|
|
|
|
|
return result.stdout.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_project_id(client: httpx.Client, project_name: str) -> str:
|
|
|
|
|
"""Find project ID by name."""
|
2026-02-11 14:33:37 -08:00
|
|
|
cursor = None
|
|
|
|
|
while True:
|
|
|
|
|
params = {}
|
|
|
|
|
if cursor:
|
|
|
|
|
params["cursor"] = cursor
|
|
|
|
|
response = client.get(f"{TODOIST_API_BASE}/projects", params=params)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
data = response.json()
|
|
|
|
|
for project in data.get("results", data if isinstance(data, list) else []):
|
|
|
|
|
if project["name"] == project_name:
|
|
|
|
|
return project["id"]
|
|
|
|
|
cursor = data.get("next_cursor") if isinstance(data, dict) else None
|
|
|
|
|
if not cursor:
|
|
|
|
|
break
|
2026-01-15 18:03:19 -08:00
|
|
|
|
|
|
|
|
raise RuntimeError(f"Project '{project_name}' not found in Todoist")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_tasks(client: httpx.Client, project_id: str) -> list[dict]:
|
|
|
|
|
"""Get all tasks for a project."""
|
2026-02-11 14:33:37 -08:00
|
|
|
tasks = []
|
|
|
|
|
cursor = None
|
|
|
|
|
while True:
|
|
|
|
|
params = {"project_id": project_id}
|
|
|
|
|
if cursor:
|
|
|
|
|
params["cursor"] = cursor
|
|
|
|
|
response = client.get(f"{TODOIST_API_BASE}/tasks", params=params)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
data = response.json()
|
|
|
|
|
tasks.extend(data.get("results", data if isinstance(data, list) else []))
|
|
|
|
|
cursor = data.get("next_cursor") if isinstance(data, dict) else None
|
|
|
|
|
if not cursor:
|
|
|
|
|
break
|
|
|
|
|
return tasks
|
2026-01-15 18:03:19 -08:00
|
|
|
|
|
|
|
|
|
2026-02-08 10:38:44 -08:00
|
|
|
def is_due(task: dict) -> bool:
|
|
|
|
|
"""Check if a task should be displayed based on its due date.
|
|
|
|
|
|
|
|
|
|
Tasks without a due date are always shown. Tasks with a due date
|
|
|
|
|
are only shown when the date is today or in the past.
|
|
|
|
|
"""
|
|
|
|
|
due = task.get("due")
|
|
|
|
|
if due is None:
|
|
|
|
|
return True
|
|
|
|
|
due_date = date.fromisoformat(due["date"][:10])
|
|
|
|
|
return due_date <= date.today()
|
|
|
|
|
|
|
|
|
|
|
2026-01-15 18:03:19 -08:00
|
|
|
def sort_tasks(tasks: list[dict]) -> list[dict]:
|
|
|
|
|
"""Sort tasks by custom priority order: p1, p2, p4, p3."""
|
|
|
|
|
return sorted(tasks, key=lambda t: PRIORITY_SORT_ORDER.get(t["priority"], 5))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
console = Console()
|
|
|
|
|
|
|
|
|
|
# Get API token
|
|
|
|
|
try:
|
|
|
|
|
token = get_todoist_token()
|
|
|
|
|
except RuntimeError as e:
|
|
|
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
# Create HTTP client with auth header
|
|
|
|
|
with httpx.Client(headers={"Authorization": f"Bearer {token}"}) as client:
|
|
|
|
|
# Find project
|
|
|
|
|
try:
|
|
|
|
|
project_id = get_project_id(client, PROJECT_NAME)
|
|
|
|
|
except RuntimeError as e:
|
|
|
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
|
|
|
return 1
|
|
|
|
|
|
2026-02-08 10:38:44 -08:00
|
|
|
# Get, filter, and sort tasks
|
2026-01-15 18:03:19 -08:00
|
|
|
tasks = get_tasks(client, project_id)
|
2026-02-08 10:38:44 -08:00
|
|
|
tasks = [t for t in tasks if is_due(t)]
|
2026-01-15 18:03:19 -08:00
|
|
|
sorted_tasks = sort_tasks(tasks)
|
|
|
|
|
|
|
|
|
|
if not sorted_tasks:
|
|
|
|
|
console.print("No tasks found in Blumeops project")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
# Display tasks
|
|
|
|
|
console.print(f"[bold]Blumeops Tasks[/bold] ({len(sorted_tasks)} tasks)")
|
|
|
|
|
console.print("=" * 40)
|
|
|
|
|
console.print()
|
|
|
|
|
|
|
|
|
|
for task in sorted_tasks:
|
|
|
|
|
priority = task["priority"]
|
|
|
|
|
label = PRIORITY_LABELS.get(priority, "p?")
|
|
|
|
|
content = task["content"]
|
|
|
|
|
description = task.get("description", "")
|
|
|
|
|
|
|
|
|
|
# Header line with priority and content
|
|
|
|
|
header = Text()
|
|
|
|
|
header.append(f"[{label}]", style="bold")
|
|
|
|
|
header.append(f" {content}")
|
|
|
|
|
console.print(header)
|
|
|
|
|
|
|
|
|
|
# Description indented
|
|
|
|
|
if description:
|
|
|
|
|
for line in description.split("\n"):
|
|
|
|
|
console.print(f" {line}", style="dim")
|
|
|
|
|
|
|
|
|
|
console.print()
|
|
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|