"""Google Tasks as a todo source. OAuth 2.0: the long-lived refresh token from the settings is exchanged for an access token per run (cached for the life of the object). Obtain the refresh token with jira-helper's `uv run google-tasks-auth` — the credentials are interchangeable, so an existing setup works here unchanged. Unlike jira-helper, undated tasks are kept by default: "someday" items are still things you can spend a pomodoro on. """ from __future__ import annotations from collections.abc import Iterator from datetime import date from typing import Any import httpx from app.settings_schema import GoogleTasksSourceSettings from app.sources.base import SourceError, TodoItem _TOKEN_URL = "https://oauth2.googleapis.com/token" _API_BASE = "https://tasks.googleapis.com/tasks/v1" _PAGE_SIZE = 100 class GoogleTasksSource: name = "google_tasks" def __init__(self, config: GoogleTasksSourceSettings, *, timeout: int = 30) -> None: if not (config.client_id and config.client_secret and config.refresh_token): raise SourceError( "Google Tasks needs a client id, client secret and refresh token." ) self._config = config self._client = httpx.Client(timeout=timeout) self._access_token: str | None = None # ---- plumbing ---------------------------------------------------------- def _token(self) -> str: if self._access_token: return self._access_token try: resp = self._client.post( _TOKEN_URL, data={ "client_id": self._config.client_id, "client_secret": self._config.client_secret, "refresh_token": self._config.refresh_token, "grant_type": "refresh_token", }, ) except httpx.HTTPError as exc: raise SourceError(f"Google is unreachable: {exc}") from exc if not resp.is_success: raise SourceError( "Could not refresh the Google access token " f"({resp.status_code}). Re-run the Google auth flow for a fresh " "refresh token." ) token = (resp.json() or {}).get("access_token") if not token: raise SourceError("Google's token response carried no access_token.") self._access_token = token return token def _get(self, path: str, params: dict[str, Any]) -> dict[str, Any]: try: resp = self._client.get( f"{_API_BASE}{path}", params=params, headers={"Authorization": f"Bearer {self._token()}"}, ) except httpx.HTTPError as exc: raise SourceError(f"Google is unreachable: {exc}") from exc if not resp.is_success: raise SourceError(f"Google 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("items", []) token = data.get("nextPageToken") if not token: return params["pageToken"] = token def _task_lists(self) -> list[dict[str, Any]]: lists = list(self._paginate("/users/@me/lists", {"maxResults": _PAGE_SIZE})) if not self._config.lists: return lists wanted = set(self._config.lists) return [ task_list for task_list in lists if wanted & {task_list.get("id", ""), task_list.get("title", "")} ] # ---- source API -------------------------------------------------------- def fetch(self) -> list[TodoItem]: items: list[TodoItem] = [] for task_list in self._task_lists(): list_id = task_list.get("id") if not list_id: continue list_title = task_list.get("title") or "(untitled list)" for raw in self._paginate( f"/lists/{list_id}/tasks", { "maxResults": _PAGE_SIZE, "showCompleted": "false", "showHidden": "false", "showDeleted": "false", }, ): if raw.get("status") == "completed": continue due = _parse_due(raw.get("due")) if due is None and not self._config.include_undated: continue task_id = raw.get("id") or "" items.append( TodoItem( source=self.name, # The list id is part of every write path, so it has to # survive the round-trip through our database. external_id=f"{list_id}:{task_id}", title=(raw.get("title") or "").strip() or "(no title)", description=(raw.get("notes") or "").strip() or None, url=raw.get("webViewLink"), project=list_title, due_date=due, raw={"list_id": list_id, "task_id": task_id}, ) ) return items def check(self) -> str: return f"{len(self._task_lists())} Google task list(s) visible" def close(self) -> None: self._client.close() def _parse_due(raw: Any) -> date | None: """Google returns RFC-3339, but only the date part is ever meaningful.""" if not raw: return None try: return date.fromisoformat(str(raw)[:10]) except ValueError: return None