- prek hooks: convert all rev = "vX.Y.Z" to commit SHAs with version comments - fly/Dockerfile: digest-pin nginx (1.30.0-alpine), tailscale (v1.94.2), and alloy (v1.16.0); bump from previous tag pins - mise-tasks: pin PEP 723 deps with == (rich 15.0.0, typer 0.25.0, pyyaml 6.0.3, httpx 0.28.1) — PEP 508 doesn't support hashes inline - prek additional_dependencies: pin ansible-lint==26.4.0, ansible-core==2.20.5 - taplo-lint: pass --no-schema (upstream catalog format changed and taplo v0.9.3 can't parse it; we don't validate against TOML schemas) - docs/update-tooling-dependencies: document SHA-pin convention, digest-pin lookup via docker buildx imagetools, and prek clean before re-verifying (cache can grow to several GiB) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
216 lines
6.8 KiB
Text
Executable file
216 lines
6.8 KiB
Text
Executable file
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# requires-python = ">=3.12"
|
|
# dependencies = ["httpx==0.28.1", "rich==15.0.0"]
|
|
# ///
|
|
#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
|
|
from datetime import date
|
|
|
|
import httpx
|
|
from rich.console import Console
|
|
from rich.markup import escape
|
|
from rich.text import Text
|
|
|
|
TODOIST_API_BASE = "https://api.todoist.com/api/v1"
|
|
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(
|
|
["op", "read", "op://vg6xf6vvfmoh5hqjjhlhbeoaie/c53h3xnmswhvexa5mntoyvhgpm/credential"],
|
|
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."""
|
|
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
|
|
|
|
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."""
|
|
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
|
|
|
|
|
|
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()
|
|
|
|
|
|
def days_until_due(task: dict) -> int | None:
|
|
"""Return signed days offset from today, or None if no due date.
|
|
|
|
Negative = days remaining before due (e.g. -2 = due in 2 days).
|
|
Positive = days past due (overdue). Zero = due today.
|
|
"""
|
|
due = task.get("due")
|
|
if due is None:
|
|
return None
|
|
due_date = date.fromisoformat(due["date"][:10])
|
|
return (date.today() - due_date).days
|
|
|
|
|
|
def recurrence_string(task: dict) -> str | None:
|
|
"""Return the Todoist natural-language recurrence string, or None.
|
|
|
|
Todoist's REST API doesn't expose RFC 5545 RRULE; the natural-language
|
|
`due.string` (e.g. "every monday", "every 2 weeks") is the terse form.
|
|
"""
|
|
due = task.get("due")
|
|
if due is None or not due.get("is_recurring"):
|
|
return None
|
|
return due.get("string")
|
|
|
|
|
|
def sort_tasks(tasks: list[dict]) -> list[dict]:
|
|
"""Sort by overdue-ness, then priority.
|
|
|
|
Most overdue first (largest +N); tasks with no due date come last.
|
|
Within a given day, tiebreaker is the custom priority order p1, p2, p4, p3.
|
|
"""
|
|
|
|
def key(task: dict) -> tuple[int, int, int]:
|
|
days = days_until_due(task)
|
|
no_due = 1 if days is None else 0
|
|
days_key = -(days if days is not None else 0) # descending
|
|
return (no_due, days_key, PRIORITY_SORT_ORDER.get(task["priority"], 5))
|
|
|
|
return sorted(tasks, key=key)
|
|
|
|
|
|
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
|
|
|
|
# Get, filter, and sort tasks
|
|
tasks = get_tasks(client, project_id)
|
|
tasks = [t for t in tasks if is_due(t)]
|
|
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}")
|
|
|
|
meta = []
|
|
days = days_until_due(task)
|
|
if days is not None:
|
|
if days == 0:
|
|
meta.append("due today")
|
|
elif days > 0:
|
|
meta.append(f"{days}d overdue")
|
|
else:
|
|
meta.append(f"due in {-days}d")
|
|
recurrence = recurrence_string(task)
|
|
if recurrence:
|
|
meta.append(f"↻ {recurrence}")
|
|
if meta:
|
|
header.append(f" ({', '.join(meta)})", style="dim")
|
|
console.print(header)
|
|
|
|
# Description indented (escape rich markup to preserve brackets)
|
|
if description:
|
|
for line in description.split("\n"):
|
|
console.print(f" {escape(line)}", style="dim")
|
|
|
|
console.print()
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|