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

+12
View File
@@ -0,0 +1,12 @@
"""Todo sources.
Each module here turns one external service into `TodoItem`s and, where the
service allows it, closes a task back in that service. The auth schemes and the
pagination quirks are the ones worked out in `~/code/jira-helper`; what is new
is that these fetch **everything open**, not just what is due today, because a
pomodoro app needs something to point the timer at.
"""
from app.sources.base import SourceError, SourceResult, TodoItem
__all__ = ["SourceError", "SourceResult", "TodoItem"]
+70
View File
@@ -0,0 +1,70 @@
"""The contract every todo source implements."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from typing import Any, Protocol
class SourceError(RuntimeError):
"""A source could not be read. Carries a message meant for the user."""
@dataclass(slots=True)
class TodoItem:
"""One todo, normalised across Jira / Google Tasks / Todoist / local."""
source: str
external_id: str
title: str
description: str | None = None
url: str | None = None
project: str | None = None
status: str | None = None
priority: str | None = None
due_date: date | None = None
start_date: date | None = None
labels: list[str] = field(default_factory=list)
is_done: bool = False
raw: dict[str, Any] = field(default_factory=dict)
def days_overdue(self, today: date) -> int:
"""Days past the due date, or 0 when due today / not dated."""
if self.due_date is None:
return 0
return max((today - self.due_date).days, 0)
@dataclass(slots=True)
class SourceResult:
"""What one source returned, including the failure if it had one.
A source that is down costs you that source, never the whole page — the same
rule jira-helper applies to its message sections.
"""
source: str
items: list[TodoItem] = field(default_factory=list)
ok: bool = True
error: str | None = None
@classmethod
def failed(cls, source: str, error: str) -> "SourceResult":
return cls(source=source, items=[], ok=False, error=error)
class TodoSource(Protocol):
"""A configured, ready-to-call source."""
name: str
def fetch(self) -> list[TodoItem]:
"""Every open todo this source knows about."""
def complete(self, item: TodoItem) -> bool:
"""Close the task in the external system. False = not supported."""
def check(self) -> str:
"""Verify credentials; return a short human description of the account."""
+175
View File
@@ -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
+184
View File
@@ -0,0 +1,184 @@
"""Jira Cloud as a todo source.
Auth is HTTP Basic with an Atlassian account email + API token, and search goes
through the token-paginated `/rest/api/3/search/jql` endpoint — both carried
over from jira-helper's client. The difference is the query: jira-helper asks
"what is due today", this asks "what is open at all", because you need
something to start a pomodoro on even when nothing is due.
Writing back is new here: finishing a Jira-backed pomodoro task transitions the
issue to Done, so the app is not a second place to keep your task list in sync.
"""
from __future__ import annotations
import re
from datetime import date
from typing import Any, Iterator
import httpx
from app.settings_schema import JiraSourceSettings
from app.sources.base import SourceError, TodoItem
_SEARCH_PATH = "/rest/api/3/search/jql"
_BASE_FIELDS = ["summary", "duedate", "status", "assignee", "priority", "project", "labels"]
_OPEN_CONDITION = "statusCategory != Done"
_CUSTOM_FIELD_RE = re.compile(r"^customfield_(\d+)$")
def start_date_clause(field_id: str) -> str:
"""`customfield_10015` has to be written `cf[10015]` in JQL; names get quoted."""
match = _CUSTOM_FIELD_RE.match(field_id)
return f"cf[{match.group(1)}]" if match else f'"{field_id}"'
class JiraSource:
name = "jira"
def __init__(self, config: JiraSourceSettings, *, timeout: int = 30) -> None:
if not (config.base_url and config.email and config.api_token):
raise SourceError("Jira needs a base URL, an account email and an API token.")
self._config = config
self._base_url = config.base_url.rstrip("/")
self._client = httpx.Client(
base_url=self._base_url,
auth=(config.email, config.api_token),
headers={"Accept": "application/json", "Content-Type": "application/json"},
timeout=timeout,
)
# ---- plumbing ----------------------------------------------------------
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
try:
resp = self._client.request(method, path, **kwargs)
except httpx.HTTPError as exc:
raise SourceError(f"Jira is unreachable: {exc}") from exc
if resp.status_code in (401, 403):
raise SourceError(
"Jira rejected the credentials (check the account email and the "
"API token — a token, never your password)."
)
if not resp.is_success:
raise SourceError(f"Jira {method} {path} -> {resp.status_code}: {resp.text[:300]}")
if resp.status_code == 204 or not resp.content:
return None
return resp.json()
def _fields(self) -> list[str]:
return [*_BASE_FIELDS, self._config.start_date_field]
def _jql(self) -> str:
"""Open issues, newest deadlines first, narrowed by the user's extra JQL."""
jql = _OPEN_CONDITION
if not self._config.include_all_open:
clause = start_date_clause(self._config.start_date_field)
jql = (
f"({_OPEN_CONDITION}) AND "
f"(duedate <= startOfDay('+7d') OR {clause} <= startOfDay())"
)
if self._config.extra_jql.strip():
jql = f"({jql}) AND ({self._config.extra_jql.strip()})"
return f"{jql} ORDER BY duedate ASC, priority DESC, updated DESC"
def _search(self, jql: str) -> Iterator[dict[str, Any]]:
payload: dict[str, Any] = {
"jql": jql,
"maxResults": 100,
"fields": self._fields(),
}
seen = 0
while True:
data = self._request("POST", _SEARCH_PATH, json=payload) or {}
for issue in data.get("issues", []):
yield issue
seen += 1
if seen >= self._config.max_issues:
return
token = data.get("nextPageToken")
if not token:
return
payload["nextPageToken"] = token
# ---- source API --------------------------------------------------------
def fetch(self) -> list[TodoItem]:
return [self._to_item(issue) for issue in self._search(self._jql())]
def check(self) -> str:
me = self._request("GET", "/rest/api/3/myself") or {}
return f"{me.get('displayName', 'unknown')} on {self._base_url}"
def complete(self, item: TodoItem) -> bool:
"""Transition the issue into a Done-category status.
Jira has no generic "close" verb: every project defines its own workflow,
so we ask the issue which transitions it offers and pick the one named in
the settings, else any transition landing in the Done category.
"""
if not self._config.writeback_done:
return False
key = item.external_id
data = self._request("GET", f"/rest/api/3/issue/{key}/transitions") or {}
transitions = data.get("transitions", [])
wanted = self._config.done_transition_name.strip().lower()
chosen = next(
(t for t in transitions if (t.get("name") or "").lower() == wanted),
None,
) or next(
(
t
for t in transitions
if ((t.get("to") or {}).get("statusCategory") or {}).get("key") == "done"
),
None,
)
if chosen is None:
raise SourceError(
f"{key} has no transition to a Done status"
+ (f" named {self._config.done_transition_name!r}" if wanted else "")
+ ". Available: "
+ ", ".join(t.get("name", "?") for t in transitions)
)
self._request(
"POST",
f"/rest/api/3/issue/{key}/transitions",
json={"transition": {"id": chosen["id"]}},
)
return True
def close(self) -> None:
self._client.close()
# ---- mapping -----------------------------------------------------------
def _to_item(self, issue: dict[str, Any]) -> TodoItem:
fields = issue.get("fields") or {}
key = issue.get("key", "")
status = (fields.get("status") or {}).get("name")
category = ((fields.get("status") or {}).get("statusCategory") or {}).get("key")
return TodoItem(
source=self.name,
external_id=key,
title=(fields.get("summary") or "").strip() or key,
url=f"{self._base_url}/browse/{key}",
project=((fields.get("project") or {}).get("name")),
status=status,
priority=(fields.get("priority") or {}).get("name"),
due_date=_parse_date(fields.get("duedate")),
start_date=_parse_date(fields.get(self._config.start_date_field)),
labels=list(fields.get("labels") or []),
is_done=category == "done",
raw={"key": key, "statusCategory": category},
)
def _parse_date(raw: Any) -> date | None:
if not raw:
return None
try:
return date.fromisoformat(str(raw)[:10])
except ValueError:
return None
+194
View File
@@ -0,0 +1,194 @@
"""Campus laundry bookings (4ka.inbicst.ru) as time commitments.
The site has no API and only offers Yandex SSO, so authentication *is* the
session cookie you copy out of your browser — the approach jira-helper settled
on. On the booking grid every slot is a `div.period`, and the ones you booked
carry an extra `my` class, which is the only thing that makes "my laundry
today" findable.
These are not todos you tick off: they are blocks of time that will interrupt
your pomodoros, so the UI shows them on the timer page and the daily report
mentions them.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import date, datetime, time
from typing import Any
from urllib.parse import urlsplit
import httpx
from bs4 import BeautifulSoup
from app.settings_schema import LaundrySourceSettings
from app.sources.base import SourceError, TodoItem
_PROFILE_PATH = "/profile/"
_GRID_PATH = "/"
_SESSION_COOKIE = "sessionid"
_COOKIE_PAIR_RE = re.compile(r"^[A-Za-z0-9_.\-]+=")
_TIME_RANGE_RE = re.compile(r"(\d{1,2}):(\d{2})\s*[-–—]\s*(\d{1,2}):(\d{2})")
_USER_AGENT = "Mozilla/5.0 (compatible; kadence laundry reader)"
_EXPIRED_HINT = (
"the laundry session cookie is no longer valid. Sign in at {base_url} in "
"your browser, copy the fresh `sessionid` cookie and paste it into "
"Settings → Sources → Laundry."
)
def parse_cookie_header(raw: str) -> dict[str, str]:
"""Accept either a bare `sessionid` value or a whole copied cookie string."""
if not _COOKIE_PAIR_RE.match(raw):
return {_SESSION_COOKIE: raw}
cookies: dict[str, str] = {}
for part in raw.split(";"):
name, _, value = part.strip().partition("=")
if name.strip() and value.strip():
cookies[name.strip()] = value.strip()
return cookies
@dataclass(slots=True)
class LaundrySlot:
location: str
machine: str
time_range: str
start: time | None
finished: bool
current: bool
refunded: bool
@property
def is_upcoming(self) -> bool:
return not self.finished and not self.refunded
class LaundrySource:
name = "laundry"
def __init__(self, config: LaundrySourceSettings, *, timeout: int = 30) -> None:
if not config.session_cookie:
raise SourceError("The laundry reader needs a `sessionid` cookie.")
cookies = parse_cookie_header(config.session_cookie)
if _SESSION_COOKIE not in cookies:
raise SourceError(
f"The laundry cookie has no {_SESSION_COOKIE!r} value in it."
)
self._base_url = config.base_url.rstrip("/")
host = urlsplit(self._base_url).hostname or ""
jar = httpx.Cookies()
for key, value in cookies.items():
jar.set(key, value, domain=host, path="/")
self._client = httpx.Client(
base_url=self._base_url,
cookies=jar,
headers={"User-Agent": _USER_AGENT},
timeout=timeout,
follow_redirects=False,
)
def _check_session(self) -> None:
"""An expired cookie silently serves the anonymous page, where none of
the slots are marked as yours — indistinguishable from "nothing booked".
So verify first."""
try:
resp = self._client.get(_PROFILE_PATH)
except httpx.HTTPError as exc:
raise SourceError(f"The laundry site is unreachable: {exc}") from exc
if resp.status_code != 200:
raise SourceError(_EXPIRED_HINT.format(base_url=self._base_url))
def fetch(self) -> list[TodoItem]:
self._check_session()
try:
resp = self._client.get(_GRID_PATH)
except httpx.HTTPError as exc:
raise SourceError(f"The laundry site is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Laundry grid -> {resp.status_code}")
_, slots = parse_my_slots(resp.text)
today = date.today()
return [
TodoItem(
source=self.name,
external_id=f"{today.isoformat()}:{slot.machine}:{slot.time_range}",
title=f"{slot.time_range} — {slot.machine} ({slot.location})",
project="Laundry",
status="running now" if slot.current else "booked",
due_date=today,
raw={
"start": slot.start.isoformat() if slot.start else None,
"current": slot.current,
},
)
for slot in slots
if slot.is_upcoming
]
def check(self) -> str:
self._check_session()
return f"signed in at {self._base_url}"
def complete(self, item: TodoItem) -> bool:
return False # the washing machine finishes on its own
def close(self) -> None:
self._client.close()
def _text(node: Any) -> str:
return node.get_text(strip=True) if node is not None else ""
def _parse_start(time_range: str) -> time | None:
match = _TIME_RANGE_RE.search(time_range)
if not match:
return None
hour, minute = int(match.group(1)), int(match.group(2))
if not (0 <= hour <= 23 and 0 <= minute <= 59):
return None
return time(hour, minute)
def parse_my_slots(html: str) -> tuple[str, list[LaundrySlot]]:
"""Pull your own slots off a booking grid. Returns `(day_label, slots)`."""
soup = BeautifulSoup(html, "html.parser")
day = soup.select_one(".date-machines")
if day is None:
raise SourceError(
"No booking grid in the response — the layout changed, or the "
"session was not accepted."
)
day_label = (day.get("id") or "").strip()
slots: list[LaundrySlot] = []
for block in day.select(".machines"):
location = _text(block.select_one(".floor-title")) or "?"
for machine in block.select(".machine"):
machine_name = _text(machine.select_one(".machine-title")) or "?"
for period in machine.select(".period.my"):
classes = set(period.get("class") or [])
time_range = _text(period.find("div")) or "?"
slots.append(
LaundrySlot(
location=location,
machine=machine_name,
time_range=time_range,
start=_parse_start(time_range),
finished="finished" in classes,
current="current" in classes,
refunded="refunded" in classes,
)
)
slots.sort(key=lambda slot: (slot.start is None, slot.start or time.min, slot.machine))
return day_label, slots
def slot_is_now(slot: LaundrySlot, moment: datetime | None = None) -> bool:
"""Whether the machine is running at `moment` (used by the timer page)."""
return slot.current
+125
View File
@@ -0,0 +1,125 @@
"""Builds the configured sources and pulls them all, independently.
Independence is the whole point: an expired laundry cookie or a Jira outage
costs you that one source and shows up as a warning beside it, never as an
empty task list.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable
from app.settings_schema import AppSettings
from app.sources.base import SourceError, SourceResult, TodoItem
from app.sources.google_tasks import GoogleTasksSource
from app.sources.jira import JiraSource
from app.sources.laundry import LaundrySource
from app.sources.todoist import TodoistSource
log = logging.getLogger(__name__)
SOURCE_LABELS = {
"jira": "Jira",
"todoist": "Todoist",
"google_tasks": "Google Tasks",
"laundry": "Laundry",
"local": "My list",
}
def build_sources(settings: AppSettings) -> dict[str, Any]:
"""Instantiate every enabled source. A misconfigured one is skipped, loudly."""
timeout = settings.sources.request_timeout_seconds
builders: list[tuple[str, Callable[[], Any]]] = [
("jira", lambda: JiraSource(settings.sources.jira, timeout=timeout)),
("todoist", lambda: TodoistSource(settings.sources.todoist, timeout=timeout)),
(
"google_tasks",
lambda: GoogleTasksSource(settings.sources.google_tasks, timeout=timeout),
),
("laundry", lambda: LaundrySource(settings.sources.laundry, timeout=timeout)),
]
enabled = {
"jira": settings.sources.jira.enabled,
"todoist": settings.sources.todoist.enabled,
"google_tasks": settings.sources.google_tasks.enabled,
"laundry": settings.sources.laundry.enabled,
}
sources: dict[str, Any] = {}
for name, build in builders:
if not enabled[name]:
continue
try:
sources[name] = build()
except SourceError as exc:
log.warning("source %s is enabled but unusable: %s", name, exc)
return sources
def _close(source: Any) -> None:
closer = getattr(source, "close", None)
if callable(closer):
try:
closer()
except Exception: # pragma: no cover
log.debug("closing %s failed", type(source).__name__, exc_info=True)
def fetch_all(settings: AppSettings) -> list[SourceResult]:
"""Fetch every enabled source in parallel; never raises."""
sources = build_sources(settings)
if not sources:
return []
def run(name: str, source: Any) -> SourceResult:
try:
items: list[TodoItem] = source.fetch()
return SourceResult(source=name, items=items)
except SourceError as exc:
return SourceResult.failed(name, str(exc))
except Exception as exc: # pragma: no cover - defensive
log.exception("source %s blew up", name)
return SourceResult.failed(name, f"unexpected error: {exc}")
try:
with ThreadPoolExecutor(max_workers=len(sources)) as pool:
futures = [pool.submit(run, name, src) for name, src in sources.items()]
return [future.result() for future in futures]
finally:
for source in sources.values():
_close(source)
def complete_remote(settings: AppSettings, item: TodoItem) -> tuple[bool, str | None]:
"""Close a task in its own system. Returns `(did_write, error)`."""
sources = build_sources(settings)
source = sources.get(item.source)
if source is None:
return False, None
try:
return bool(source.complete(item)), None
except SourceError as exc:
return False, str(exc)
finally:
for built in sources.values():
_close(built)
def check_source(settings: AppSettings, name: str) -> tuple[bool, str]:
"""Verify one source's credentials — what the settings page's Test button calls."""
sources = build_sources(settings)
source = sources.get(name)
if source is None:
return False, f"{SOURCE_LABELS.get(name, name)} is not enabled or not configured."
try:
return True, source.check()
except SourceError as exc:
return False, str(exc)
except Exception as exc: # pragma: no cover
return False, f"unexpected error: {exc}"
finally:
for built in sources.values():
_close(built)
+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