"""Todoist as a todo source. A personal API token sent as a Bearer token — no OAuth dance. jira-helper asks Todoist's own `overdue | today` filter so Todoist's timezone and recurrence rules decide what is due; here the default is *every* open task (you need something to point the timer at), with the filter still available as a setting for people who want the narrower list. """ from __future__ import annotations from collections.abc import Iterator from datetime import date from typing import Any import httpx from app.settings_schema import TodoistSourceSettings from app.sources.base import SourceError, TodoItem _API_BASE = "https://api.todoist.com/api/v1" _TASK_URL = "https://app.todoist.com/app/task/{task_id}" _PAGE_SIZE = 200 # Todoist stores priority upside down from how its apps label it: 4 is "p1". _PRIORITY_LABELS = {4: "p1", 3: "p2", 2: "p3"} class TodoistSource: name = "todoist" def __init__(self, config: TodoistSourceSettings, *, timeout: int = 30) -> None: if not config.api_token: raise SourceError("Todoist needs an API token (Settings → Integrations).") self._config = config self._client = httpx.Client( base_url=_API_BASE, headers={ "Accept": "application/json", "Authorization": f"Bearer {config.api_token}", }, timeout=timeout, ) # ---- plumbing ---------------------------------------------------------- def _get(self, path: str, params: dict[str, Any]) -> dict[str, Any]: try: resp = self._client.get(path, params=params) except httpx.HTTPError as exc: raise SourceError(f"Todoist is unreachable: {exc}") from exc if resp.status_code in (401, 403): raise SourceError("Todoist rejected the API token.") if not resp.is_success: raise SourceError(f"Todoist GET {path} -> {resp.status_code}: {resp.text[:300]}") return resp.json() or {} def _paginate(self, path: str, params: dict[str, Any]) -> Iterator[dict[str, Any]]: params = dict(params) while True: data = self._get(path, params) yield from data.get("results", []) cursor = data.get("next_cursor") if not cursor: return params["cursor"] = cursor def _projects(self) -> dict[str, str]: return { project["id"]: project.get("name") or "(untitled project)" for project in self._paginate("/projects", {"limit": _PAGE_SIZE}) if project.get("id") } def _selected(self, project_id: str, project_name: str) -> bool: if not self._config.projects: return True wanted = set(self._config.projects) return bool(wanted & {project_id, project_name}) # ---- source API -------------------------------------------------------- def fetch(self) -> list[TodoItem]: projects = self._projects() query = self._config.filter_query.strip() if query: raw_tasks = self._paginate("/tasks/filter", {"query": query, "limit": _PAGE_SIZE}) else: raw_tasks = self._paginate("/tasks", {"limit": _PAGE_SIZE}) items: list[TodoItem] = [] for raw in raw_tasks: if raw.get("checked") or raw.get("is_deleted"): continue project_id = raw.get("project_id") or "" project_name = projects.get(project_id, "(unknown project)") if not self._selected(project_id, project_name): continue task_id = str(raw.get("id") or "") due = raw.get("due") or {} items.append( TodoItem( source=self.name, external_id=task_id, title=(raw.get("content") or "").strip() or "(no content)", description=(raw.get("description") or "").strip() or None, url=_TASK_URL.format(task_id=task_id) if task_id else None, project=project_name, priority=_PRIORITY_LABELS.get(raw.get("priority")), due_date=_parse_due(due), labels=list(raw.get("labels") or []), raw={"recurring": bool(due.get("is_recurring"))}, ) ) return items def check(self) -> str: projects = self._projects() return f"{len(projects)} Todoist project(s) visible" def close(self) -> None: self._client.close() def _parse_due(due: Any) -> date | None: if not isinstance(due, dict): return None raw = due.get("date") if not raw: return None try: return date.fromisoformat(str(raw)[:10]) except ValueError: return None