1111from __future__ import annotations
1212
1313import logging
14+ import uuid
1415
1516try :
1617 import niquests as requests
1920 import requests # type: ignore[no-redef]
2021 from requests .auth import HTTPBasicAuth # type: ignore[no-redef]
2122
22- from caldav .jmap .constants import CALENDAR_CAPABILITY , CORE_CAPABILITY
23+ from caldav .jmap .constants import CALENDAR_CAPABILITY , CORE_CAPABILITY , TASK_CAPABILITY
2324from caldav .jmap .convert import ical_to_jscal , jscal_to_ical
2425from caldav .jmap .error import JMAPAuthError , JMAPMethodError
2526from caldav .jmap .methods .calendar import build_calendar_get , parse_calendar_get
3233 parse_event_changes ,
3334 parse_event_set ,
3435)
36+ from caldav .jmap .methods .task import (
37+ build_task_get ,
38+ build_task_list_get ,
39+ build_task_set_create ,
40+ build_task_set_destroy ,
41+ build_task_set_update ,
42+ parse_task_list_get ,
43+ parse_task_set ,
44+ )
3545from caldav .jmap .objects .calendar import JMAPCalendar
46+ from caldav .jmap .objects .task import JMAPTask , JMAPTaskList
3647from caldav .jmap .session import Session , fetch_session
3748from caldav .requests import HTTPBearerAuth
3849
3950log = logging .getLogger ("caldav.jmap" )
4051
4152_DEFAULT_USING = [CORE_CAPABILITY , CALENDAR_CAPABILITY ]
53+ _TASK_USING = [CORE_CAPABILITY , TASK_CAPABILITY ]
4254
4355
4456class JMAPClient :
@@ -130,7 +142,7 @@ def _build_auth(self, auth_type: str | None):
130142 def _raise_set_error (self , session : Session , err : dict ) -> None :
131143 raise JMAPMethodError (
132144 url = session .api_url ,
133- reason = f"CalendarEvent/ set failed: { err } " ,
145+ reason = f"set failed: { err } " ,
134146 error_type = err .get ("type" , "serverError" ),
135147 )
136148
@@ -140,11 +152,13 @@ def _get_session(self) -> Session:
140152 self ._session_cache = fetch_session (self .url , auth = self ._auth , timeout = self .timeout )
141153 return self ._session_cache
142154
143- def _request (self , method_calls : list [tuple ]) -> list :
155+ def _request (self , method_calls : list [tuple ], using : list [ str ] | None = None ) -> list :
144156 """POST a batch of JMAP method calls and return the methodResponses.
145157
146158 Args:
147159 method_calls: List of 3-tuples ``(method_name, args_dict, call_id)``.
160+ using: Capability URN list for the ``using`` field. Defaults to
161+ ``_DEFAULT_USING`` (core + calendars).
148162
149163 Returns:
150164 List of 3-tuples ``(method_name, response_args, call_id)`` from
@@ -158,7 +172,7 @@ def _request(self, method_calls: list[tuple]) -> list:
158172 session = self ._get_session ()
159173
160174 payload = {
161- "using" : _DEFAULT_USING ,
175+ "using" : using if using is not None else _DEFAULT_USING ,
162176 "methodCalls" : list (method_calls ),
163177 }
164178
@@ -450,3 +464,129 @@ def delete_event(self, event_id: str) -> None:
450464 return
451465
452466 raise JMAPMethodError (url = session .api_url , reason = "No CalendarEvent/set response" )
467+
468+ def get_task_lists (self ) -> list [JMAPTaskList ]:
469+ """Fetch all task lists for the authenticated account.
470+
471+ Returns:
472+ List of :class:`~caldav.jmap.objects.task.JMAPTaskList` objects.
473+ """
474+ session = self ._get_session ()
475+ call = build_task_list_get (session .account_id )
476+ responses = self ._request ([call ], using = _TASK_USING )
477+
478+ for method_name , resp_args , _ in responses :
479+ if method_name == "TaskList/get" :
480+ return parse_task_list_get (resp_args )
481+
482+ return []
483+
484+ def create_task (self , task_list_id : str , title : str , ** kwargs ) -> str :
485+ """Create a task in a task list.
486+
487+ Args:
488+ task_list_id: The JMAP task list ID to create the task in.
489+ title: Task title (maps to VTODO ``SUMMARY``).
490+ **kwargs: Optional task fields: ``description``, ``due``, ``start``,
491+ ``time_zone``, ``estimated_duration``, ``percent_complete``,
492+ ``progress``, ``priority``.
493+
494+ Returns:
495+ The server-assigned JMAP task ID.
496+
497+ Raises:
498+ JMAPMethodError: If the server rejects the create request.
499+ """
500+ session = self ._get_session ()
501+ task = JMAPTask (
502+ id = "" ,
503+ uid = str (uuid .uuid4 ()),
504+ task_list_id = task_list_id ,
505+ title = title ,
506+ ** kwargs ,
507+ )
508+ call = build_task_set_create (session .account_id , {"new-0" : task })
509+ responses = self ._request ([call ], using = _TASK_USING )
510+
511+ for method_name , resp_args , _ in responses :
512+ if method_name == "Task/set" :
513+ created , _ , _ , not_created , _ , _ = parse_task_set (resp_args )
514+ if "new-0" in not_created :
515+ self ._raise_set_error (session , not_created ["new-0" ])
516+ return created ["new-0" ]["id" ]
517+
518+ raise JMAPMethodError (url = session .api_url , reason = "No Task/set response" )
519+
520+ def get_task (self , task_id : str ) -> JMAPTask :
521+ """Fetch a task by ID.
522+
523+ Args:
524+ task_id: The JMAP task ID to retrieve.
525+
526+ Returns:
527+ A :class:`~caldav.jmap.objects.task.JMAPTask` object.
528+
529+ Raises:
530+ JMAPMethodError: If the task is not found.
531+ """
532+ session = self ._get_session ()
533+ call = build_task_get (session .account_id , ids = [task_id ])
534+ responses = self ._request ([call ], using = _TASK_USING )
535+
536+ for method_name , resp_args , _ in responses :
537+ if method_name == "Task/get" :
538+ items = resp_args .get ("list" , [])
539+ if not items :
540+ raise JMAPMethodError (
541+ url = session .api_url ,
542+ reason = f"Task not found: { task_id } " ,
543+ error_type = "notFound" ,
544+ )
545+ return JMAPTask .from_jmap (items [0 ])
546+
547+ raise JMAPMethodError (url = session .api_url , reason = "No Task/get response" )
548+
549+ def update_task (self , task_id : str , patch : dict ) -> None :
550+ """Update a task with a partial patch.
551+
552+ Args:
553+ task_id: The JMAP task ID to update.
554+ patch: Partial patch dict mapping property names to new values.
555+
556+ Raises:
557+ JMAPMethodError: If the server rejects the update.
558+ """
559+ session = self ._get_session ()
560+ call = build_task_set_update (session .account_id , {task_id : patch })
561+ responses = self ._request ([call ], using = _TASK_USING )
562+
563+ for method_name , resp_args , _ in responses :
564+ if method_name == "Task/set" :
565+ _ , _ , _ , _ , not_updated , _ = parse_task_set (resp_args )
566+ if task_id in not_updated :
567+ self ._raise_set_error (session , not_updated [task_id ])
568+ return
569+
570+ raise JMAPMethodError (url = session .api_url , reason = "No Task/set response" )
571+
572+ def delete_task (self , task_id : str ) -> None :
573+ """Delete a task.
574+
575+ Args:
576+ task_id: The JMAP task ID to delete.
577+
578+ Raises:
579+ JMAPMethodError: If the server rejects the delete.
580+ """
581+ session = self ._get_session ()
582+ call = build_task_set_destroy (session .account_id , [task_id ])
583+ responses = self ._request ([call ], using = _TASK_USING )
584+
585+ for method_name , resp_args , _ in responses :
586+ if method_name == "Task/set" :
587+ _ , _ , _ , _ , _ , not_destroyed = parse_task_set (resp_args )
588+ if task_id in not_destroyed :
589+ self ._raise_set_error (session , not_destroyed [task_id ])
590+ return
591+
592+ raise JMAPMethodError (url = session .api_url , reason = "No Task/set response" )
0 commit comments