init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,175 @@
|
||||
"""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 datetime import date
|
||||
from typing import Any, Iterator
|
||||
|
||||
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 complete(self, item: TodoItem) -> bool:
|
||||
"""PATCH the task's status — Google Tasks has no dedicated close verb."""
|
||||
if not self._config.writeback_done:
|
||||
return False
|
||||
list_id, _, task_id = item.external_id.partition(":")
|
||||
if not (list_id and task_id):
|
||||
raise SourceError(f"Unusable Google task id: {item.external_id!r}")
|
||||
try:
|
||||
resp = self._client.patch(
|
||||
f"{_API_BASE}/lists/{list_id}/tasks/{task_id}",
|
||||
json={"status": "completed"},
|
||||
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 could not complete the task: {resp.status_code}")
|
||||
return True
|
||||
|
||||
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
|
||||
Reference in new issue
Block a user