from pathlib import PurePath
from typing import Optional, AsyncIterator, AsyncContextManager
from urllib.parse import quote as url_quote
from uuid import UUID, uuid4
from .error import ApiError
from .parser import DavParser
from .request import Requester, Request
from .task import TaskFile, TaskList
class NextcloudTasksApi(AsyncContextManager):
"""
Main API interface, using a backend for XML DAV requests, giving raw ICAL data.
Must be entered/exited (or opened/closed) to set up the request backend with authentication and connection pool.
Note that some endpoints do not return the updated or created content. To avoid the additional fetch roundtrip,
a local copy will be returned if possible (i.e., when the new ETag is available).
"""
def __init__(self, requester: Requester, parser: DavParser = DavParser()) -> None:
self._requester: Requester = requester
self._parser: DavParser = parser
async def list_user_principal(self) -> AsyncIterator[str]:
"""Query current user principal endpoints. Useful to validate endpoint and authentication settings."""
request: Request = Request(
method="PROPFIND",
url="remote.php/dav/",
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"},
content=self._parser.get_user_principal(),
)
async with self._requester.request(request) as response:
response.raise_for(207, "application/xml", "utf-8")
async for href in self._parser.parse_for_user_principal(response):
yield self._make_href(href).as_posix()
async def get_lists(self) -> AsyncIterator[TaskList]:
"""Find all available task lists."""
request: Request = Request(
method="PROPFIND",
url="remote.php/dav/calendars/{}/".format(self._url_quote(self._requester.username)),
headers={"Content-Type": "application/xml; charset=utf-8"},
content=self._parser.get_propfind_calendar_list(),
)
async with self._requester.request(request) as response:
response.raise_for(207, "application/xml", "utf-8")
async for href, name, color in self._parser.parse_for_calendar_list(response):
yield TaskList(href=self._make_href(href), name=name, color=color)
async def get_list(self, task_list: TaskList, completed: Optional[bool] = None) -> AsyncIterator[TaskFile]:
"""Get all tasks of a task list, optionally filter remotely by completion state."""
request: Request = Request(
method="REPORT",
url=task_list.href.as_posix(),
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"},
content=self._parser.get_report_calendars(completed),
)
async with self._requester.request(request) as response:
response.raise_for(207, "application/xml", "utf-8")
async for href, etag, caldav in self._parser.parse_for_calendars(response):
yield TaskFile(href=self._make_href(href), etag=etag, content=caldav)
async def create_list(self, name: str, color: str = "#0082c9", filename: Optional[str] = None) -> TaskList:
"""Create a new task list with the given displayname and (unverified) filename."""
url: PurePath = PurePath("remote.php/dav/calendars") / \
self._url_quote(self._requester.username) / \
self._url_quote(filename if filename is not None else name.lower())
request: Request = Request(
method="MKCOL",
url=url.as_posix(),
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"},
content=self._parser.get_mkcol_calendar(name, color),
)
async with self._requester.request(request) as response:
response.raise_for(201)
return TaskList(href=url, name=name, color=color)
async def update_list(self, task_list: TaskList) -> None:
"""Update a task list with its updated handle."""
request: Request = Request(
method="PROPPATCH",
url=task_list.href.as_posix(),
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"},
content=self._parser.get_property_update(
task_list.name if task_list.name is not None else task_list.href.stem,
task_list.color if task_list.color is not None else "#0082c9",
)
)
async with self._requester.request(request) as response:
response.raise_for(207, "application/xml", "utf-8")
if [self._make_href(_) async for _ in self._parser.parse_propstat_for_status(response)] != [task_list.href]:
raise ApiError(f"Cannot update tasklist '{task_list.href}'") from None
async def delete_list(self, task_list: TaskList) -> None:
"""Delete the given task list."""
request: Request = Request(
method="DELETE",
headers={"Depth": "0"},
url=task_list.href.as_posix(),
)
async with self._requester.request(request) as response:
response.raise_for(204)
async def create_task(self, task_list: TaskList, task: str, filename: Optional[UUID] = None) -> TaskFile:
"""Create a new task in the task list with the given iCal content."""
href: PurePath = task_list.href
href /= PurePath(str(filename if filename is not None else uuid4()).upper()).with_suffix(".ics")
request: Request = Request(
method="PUT",
url=href.as_posix(),
headers={"Content-Type": "text/calendar; charset=utf-8"},
content=task.encode(encoding="utf-8", errors="strict"),
)
async with self._requester.request(request) as response:
response.raise_for(201)
etag: Optional[str] = response.headers.get("oc-etag", response.headers.get("etag"))
if etag is not None:
return TaskFile(href=href, etag=etag, content=task)
return await self._get_task(href)
async def update_task(self, task: TaskFile) -> TaskFile:
"""Update a task with new content."""
request: Request = Request(
method="PUT",
url=task.href.as_posix(),
headers={**{"Content-Type": "text/calendar; component=vtodo; charset=utf-8"},
**({"If-Match": task.etag} if task.etag is not None else {})},
content=task.content.encode(encoding="utf-8", errors="strict"),
)
async with self._requester.request(request) as response:
response.raise_for(204)
etag: Optional[str] = response.headers.get("oc-etag", response.headers.get("etag"))
if etag is not None and task.etag is not None:
return TaskFile(href=task.href, etag=etag, content=task.content)
return await self._get_task(task.href)
async def delete_task(self, task: TaskFile) -> None:
"""Delete a task. This does not recursively delete subtasks."""
request: Request = Request(
method="DELETE",
headers={"If-Match": task.etag} if task.etag is not None else {},
url=task.href.as_posix(),
)
async with self._requester.request(request) as response:
response.raise_for(204)
async def _get_task(self, task_href: PurePath) -> TaskFile:
request: Request = Request(
method="PROPFIND",
url=task_href.as_posix(),
headers={"Content-Type": "application/xml; charset=utf-8"},
content=self._parser.get_propfind_calendar(),
)
async with self._requester.request(request) as response:
response.raise_for(207, "application/xml", "utf-8")
task = await self._parser.parse_for_calendar(response)
if task is None:
raise ApiError(f"Cannot get task '{task_href}'")
href, etag, caldav = task
return TaskFile(href=self._make_href(href), etag=etag, content=caldav)
@classmethod
def _make_href(cls, href: str) -> PurePath:
"""Treat all 'remote.php' API URLs as relative to configured base URL, even when returned absolute."""
return PurePath(href.lstrip("/") if href.startswith("/remote.php/") else href)
@classmethod
def _url_quote(cls, s: str) -> str:
"""Make the given string safe to be used in URL path context."""
return url_quote(s, safe='')
async def __aenter__(self) -> 'NextcloudTasksApi':
await self._requester.__aenter__()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
await self._requester.__aexit__(exc_type, exc_val, exc_tb)
async def open(self) -> None:
await self.__aenter__()
async def close(self) -> None:
await self.__aexit__(None, None, None)
@property
def username(self) -> str:
return self._requester.username
@property
def base_url(self) -> str:
return self._requester.base_url
def __str__(self) -> str:
return f"<{self.__class__.__name__} {self.username} @ {self.base_url}>"