#!/usr/bin/env python3 """Fetch and update remote my2dos tasks via the JSON API. Configuration is read from environment variables or from .env in the project root: - MY2DOS_API_URL, e.g. https://my2dos.rucki.ch - MY2DOS_API_KEY, API key token """ from __future__ import annotations import argparse import json import os import sys from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen ROOT = Path(__file__).resolve().parents[1] def load_dotenv(path: Path = ROOT / ".env") -> None: if not path.exists(): return for raw_line in path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) def config() -> tuple[str, str]: load_dotenv() base_url = os.environ.get("MY2DOS_API_URL", "").rstrip("/") api_key = os.environ.get("MY2DOS_API_KEY", "") if not base_url or not api_key: print("Missing MY2DOS_API_URL or MY2DOS_API_KEY. Configure them in .env.", file=sys.stderr) sys.exit(2) return base_url, api_key def request_json(method: str, path: str, data: dict[str, Any] | None = None, query: dict[str, str] | None = None) -> Any: base_url, api_key = config() url = f"{base_url}{path}" if query: url = f"{url}?{urlencode(query)}" body = json.dumps(data).encode("utf-8") if data is not None else None request = Request( url, data=body, method=method, headers={"X-Api-Key": api_key, "Accept": "application/json", "Content-Type": "application/json"}, ) try: with urlopen(request, timeout=20) as response: return json.loads(response.read().decode("utf-8")) except HTTPError as exc: details = exc.read().decode("utf-8", errors="replace") print(f"HTTP {exc.code} for {method} {url}: {details}", file=sys.stderr) sys.exit(1) except URLError as exc: print(f"Request failed for {method} {url}: {exc}", file=sys.stderr) sys.exit(1) def list_tasks(args: argparse.Namespace) -> None: payload = request_json("GET", "/api/items/", query={"limit": str(args.limit)}) items = payload.get("items", []) tasks = [item for item in items if item.get("kind") == "todo"] if args.open: tasks = [item for item in tasks if not item.get("is_done")] for item in tasks: status = "x" if item.get("is_done") else " " due = f" due:{item['due_at'][:10]}" if item.get("due_at") else "" tags = " ".join(f"#{tag}" for tag in item.get("tags", [])) print(f"[{status}] #{item['id']}{due} {item.get('content', '').strip()} {tags}".rstrip()) def mark_done(args: argparse.Namespace) -> None: item = request_json("PATCH", f"/api/items/{args.id}/", data={"is_done": True}) print(f"Done: #{item['id']} {item.get('content', '').strip()}") def main() -> None: parser = argparse.ArgumentParser(description="Work with remote my2dos tasks.") subparsers = parser.add_subparsers(dest="command") list_parser = subparsers.add_parser("list", help="List remote todos") list_parser.add_argument("--all", dest="open", action="store_false", help="include completed todos") list_parser.add_argument("--limit", type=int, default=500) list_parser.set_defaults(func=list_tasks, open=True) done_parser = subparsers.add_parser("done", help="Mark a todo as done") done_parser.add_argument("id", type=int) done_parser.set_defaults(func=mark_done) args = parser.parse_args() if not hasattr(args, "func"): args = parser.parse_args(["list"]) args.func(args) if __name__ == "__main__": main()