This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
"""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 datetime import date
from typing import Any, Iterator
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 complete(self, item: TodoItem) -> bool:
"""`POST /tasks/{id}/close` — the same verb the apps' checkbox uses."""
if not self._config.writeback_done:
return False
try:
resp = self._client.post(f"/tasks/{item.external_id}/close")
except httpx.HTTPError as exc:
raise SourceError(f"Todoist is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Todoist could not close the task: {resp.status_code}")
return True
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