init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -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"]
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user