108 lines
3.8 KiB
Python
Executable File
108 lines
3.8 KiB
Python
Executable File
#!/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()
|