Jellyfin(8096), OrbStack(8097) 포트 충돌으로 변경. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1550 lines
58 KiB
Python
1550 lines
58 KiB
Python
"""
|
|
I'm trying to be consistent with the terminology in the RFCs:
|
|
|
|
CalendarSet is a collection of Calendars
|
|
Calendar is a collection of CalendarObjectResources
|
|
Principal is not a collection, but holds a CalendarSet.
|
|
|
|
There are also some Mailbox classes to deal with RFC6638.
|
|
|
|
A SynchronizableCalendarObjectCollection contains a local copy of objects from a calendar on the server.
|
|
"""
|
|
import logging
|
|
import sys
|
|
import uuid
|
|
import warnings
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from time import sleep
|
|
from typing import Any
|
|
from typing import List
|
|
from typing import Optional
|
|
from typing import Tuple
|
|
from typing import TYPE_CHECKING
|
|
from typing import TypeVar
|
|
from typing import Union
|
|
from urllib.parse import ParseResult
|
|
from urllib.parse import quote
|
|
from urllib.parse import SplitResult
|
|
from urllib.parse import unquote
|
|
|
|
import icalendar
|
|
from icalendar.caselessdict import CaselessDict
|
|
|
|
try:
|
|
from typing import ClassVar, Optional, Union, Type
|
|
|
|
TimeStamp = Optional[Union[date, datetime]]
|
|
except:
|
|
pass
|
|
|
|
if TYPE_CHECKING:
|
|
from icalendar import vCalAddress
|
|
|
|
from .davclient import DAVClient
|
|
|
|
if sys.version_info < (3, 9):
|
|
from typing import Callable, Container, Iterable, Iterator, Sequence
|
|
|
|
from typing_extensions import DefaultDict, Literal
|
|
else:
|
|
from collections import defaultdict as DefaultDict
|
|
from collections.abc import Callable, Container, Iterable, Iterator, Sequence
|
|
from typing import Literal
|
|
|
|
if sys.version_info < (3, 11):
|
|
from typing_extensions import Self
|
|
else:
|
|
from typing import Self
|
|
|
|
from .calendarobjectresource import CalendarObjectResource
|
|
from .calendarobjectresource import Event
|
|
from .calendarobjectresource import FreeBusy
|
|
from .calendarobjectresource import Journal
|
|
from .calendarobjectresource import Todo
|
|
from .davobject import DAVObject
|
|
from .elements.cdav import CalendarData
|
|
from .elements import cdav
|
|
from .elements import dav
|
|
from .lib import error
|
|
from .lib import vcal
|
|
from .lib.python_utilities import to_wire
|
|
from .lib.url import URL
|
|
|
|
_CC = TypeVar("_CC", bound="CalendarObjectResource")
|
|
log = logging.getLogger("caldav")
|
|
|
|
|
|
class CalendarSet(DAVObject):
|
|
"""
|
|
A CalendarSet is a set of calendars.
|
|
"""
|
|
|
|
def calendars(self) -> List["Calendar"]:
|
|
"""
|
|
List all calendar collections in this set.
|
|
|
|
Returns:
|
|
* [Calendar(), ...]
|
|
"""
|
|
cals = []
|
|
|
|
data = self.children(cdav.Calendar.tag)
|
|
for c_url, c_type, c_name in data:
|
|
try:
|
|
cal_id = c_url.split("/")[-2]
|
|
if not cal_id:
|
|
continue
|
|
except:
|
|
log.error(f"Calendar {c_name} has unexpected url {c_url}")
|
|
cal_id = None
|
|
cals.append(
|
|
Calendar(self.client, id=cal_id, url=c_url, parent=self, name=c_name)
|
|
)
|
|
|
|
return cals
|
|
|
|
def make_calendar(
|
|
self,
|
|
name: Optional[str] = None,
|
|
cal_id: Optional[str] = None,
|
|
supported_calendar_component_set: Optional[Any] = None,
|
|
method=None,
|
|
) -> "Calendar":
|
|
"""
|
|
Utility method for creating a new calendar.
|
|
|
|
Args:
|
|
name: the display name of the new calendar
|
|
cal_id: the uuid of the new calendar
|
|
supported_calendar_component_set: what kind of objects
|
|
(EVENT, VTODO, VFREEBUSY, VJOURNAL) the calendar should handle.
|
|
Should be set to ['VTODO'] when creating a task list in Zimbra -
|
|
in most other cases the default will be OK.
|
|
|
|
Returns:
|
|
Calendar(...)-object
|
|
"""
|
|
return Calendar(
|
|
self.client,
|
|
name=name,
|
|
parent=self,
|
|
id=cal_id,
|
|
supported_calendar_component_set=supported_calendar_component_set,
|
|
).save(method=method)
|
|
|
|
def calendar(
|
|
self, name: Optional[str] = None, cal_id: Optional[str] = None
|
|
) -> "Calendar":
|
|
"""
|
|
The calendar method will return a calendar object. If it gets a cal_id
|
|
but no name, it will not initiate any communication with the server
|
|
|
|
Args:
|
|
name: return the calendar with this display name
|
|
cal_id: return the calendar with this calendar id or URL
|
|
|
|
Returns:
|
|
Calendar(...)-object
|
|
"""
|
|
if name and not cal_id:
|
|
for calendar in self.calendars():
|
|
display_name = calendar.get_display_name()
|
|
if display_name == name:
|
|
return calendar
|
|
if name and not cal_id:
|
|
raise error.NotFoundError(
|
|
"No calendar with name %s found under %s" % (name, self.url)
|
|
)
|
|
if not cal_id and not name:
|
|
cals = self.calendars()
|
|
if not cals:
|
|
raise error.NotFoundError("no calendars found")
|
|
return cals[0]
|
|
|
|
if self.client is None:
|
|
raise ValueError("Unexpected value None for self.client")
|
|
|
|
if cal_id is None:
|
|
raise ValueError("Unexpected value None for cal_id")
|
|
|
|
if str(URL.objectify(cal_id).canonical()).startswith(
|
|
str(self.client.url.canonical())
|
|
):
|
|
url = self.client.url.join(cal_id)
|
|
elif isinstance(cal_id, URL) or (
|
|
isinstance(cal_id, str)
|
|
and (cal_id.startswith("https://") or cal_id.startswith("http://"))
|
|
):
|
|
if self.url is None:
|
|
raise ValueError("Unexpected value None for self.url")
|
|
|
|
url = self.url.join(cal_id)
|
|
else:
|
|
if self.url is None:
|
|
raise ValueError("Unexpected value None for self.url")
|
|
|
|
if cal_id is None:
|
|
raise ValueError("Unexpected value None for cal_id")
|
|
|
|
url = self.url.join(quote(cal_id) + "/")
|
|
|
|
return Calendar(self.client, name=name, parent=self, url=url, id=cal_id)
|
|
|
|
|
|
class Principal(DAVObject):
|
|
"""
|
|
This class represents a DAV Principal. It doesn't do much, except
|
|
keep track of the URLs for the calendar-home-set, etc.
|
|
|
|
A principal MUST have a non-empty DAV:displayname property
|
|
(defined in Section 13.2 of [RFC2518]),
|
|
and a DAV:resourcetype property (defined in Section 13.9 of [RFC2518]).
|
|
Additionally, a principal MUST report the DAV:principal XML element
|
|
in the value of the DAV:resourcetype property.
|
|
|
|
(TODO: the resourcetype is actually never checked, and the DisplayName
|
|
is not stored anywhere)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
client: Optional["DAVClient"] = None,
|
|
url: Union[str, ParseResult, SplitResult, URL, None] = None,
|
|
calendar_home_set: URL = None,
|
|
**kwargs, ## to be passed to super.__init__
|
|
) -> None:
|
|
"""
|
|
Returns a Principal.
|
|
|
|
End-users usually shouldn't need to construct Principal-objects directly. Use davclient.principal() to get the principal object of the logged-in user and davclient.principals() to get other principals.
|
|
|
|
Args:
|
|
client: a DAVClient() object
|
|
url: The URL, if known.
|
|
calendar_home_set: the calendar home set, if known
|
|
|
|
If url is not given, deduct principal path as well as calendar home set
|
|
path from doing propfinds.
|
|
"""
|
|
self._calendar_home_set = calendar_home_set
|
|
|
|
super(Principal, self).__init__(client=client, url=url, **kwargs)
|
|
if url is None:
|
|
if self.client is None:
|
|
raise ValueError("Unexpected value None for self.client")
|
|
|
|
self.url = self.client.url
|
|
cup = self.get_property(dav.CurrentUserPrincipal())
|
|
|
|
if cup is None:
|
|
log.warning("calendar server lacking a feature:")
|
|
log.warning("current-user-principal property not found")
|
|
log.warning("assuming %s is the principal URL" % self.client.url)
|
|
|
|
self.url = self.client.url.join(URL.objectify(cup))
|
|
|
|
def make_calendar(
|
|
self,
|
|
name: Optional[str] = None,
|
|
cal_id: Optional[str] = None,
|
|
supported_calendar_component_set: Optional[Any] = None,
|
|
method=None,
|
|
) -> "Calendar":
|
|
"""
|
|
Convenience method, bypasses the self.calendar_home_set object.
|
|
See CalendarSet.make_calendar for details.
|
|
"""
|
|
return self.calendar_home_set.make_calendar(
|
|
name,
|
|
cal_id,
|
|
supported_calendar_component_set=supported_calendar_component_set,
|
|
method=method,
|
|
)
|
|
|
|
def calendar(
|
|
self,
|
|
name: Optional[str] = None,
|
|
cal_id: Optional[str] = None,
|
|
cal_url: Optional[str] = None,
|
|
) -> "Calendar":
|
|
"""
|
|
The calendar method will return a calendar object.
|
|
It will not initiate any communication with the server.
|
|
"""
|
|
if not cal_url:
|
|
return self.calendar_home_set.calendar(name, cal_id)
|
|
else:
|
|
if self.client is None:
|
|
raise ValueError("Unexpected value None for self.client")
|
|
|
|
return Calendar(self.client, url=self.client.url.join(cal_url))
|
|
|
|
def get_vcal_address(self) -> "vCalAddress":
|
|
"""
|
|
Returns the principal, as an icalendar.vCalAddress object.
|
|
"""
|
|
from icalendar import vCalAddress, vText
|
|
|
|
cn = self.get_display_name()
|
|
ids = self.calendar_user_address_set()
|
|
cutype = self.get_property(cdav.CalendarUserType())
|
|
ret = vCalAddress(ids[0])
|
|
ret.params["cn"] = vText(cn)
|
|
ret.params["cutype"] = vText(cutype)
|
|
return ret
|
|
|
|
@property
|
|
def calendar_home_set(self):
|
|
if not self._calendar_home_set:
|
|
calendar_home_set_url = self.get_property(cdav.CalendarHomeSet())
|
|
## owncloud returns /remote.php/dav/calendars/tobixen@e.email/
|
|
## in that case the @ should be quoted. Perhaps other
|
|
## implementations return already quoted URLs. Hacky workaround:
|
|
if (
|
|
calendar_home_set_url is not None
|
|
and "@" in calendar_home_set_url
|
|
and "://" not in calendar_home_set_url
|
|
):
|
|
calendar_home_set_url = quote(calendar_home_set_url)
|
|
self.calendar_home_set = calendar_home_set_url
|
|
return self._calendar_home_set
|
|
|
|
@calendar_home_set.setter
|
|
def calendar_home_set(self, url) -> None:
|
|
if isinstance(url, CalendarSet):
|
|
self._calendar_home_set = url
|
|
return
|
|
sanitized_url = URL.objectify(url)
|
|
## TODO: sanitized_url should never be None, this needs more
|
|
## research. added here as it solves real-world issues, ref
|
|
## https://github.com/python-caldav/caldav/pull/56
|
|
if sanitized_url is not None:
|
|
if (
|
|
sanitized_url.hostname
|
|
and sanitized_url.hostname != self.client.url.hostname
|
|
):
|
|
# icloud (and others?) having a load balanced system,
|
|
# where each principal resides on one named host
|
|
## TODO:
|
|
## Here be dragons. sanitized_url will be the root
|
|
## of all future objects derived from client. Changing
|
|
## the client.url root by doing a principal.calendars()
|
|
## is an unacceptable side effect and may be a cause of
|
|
## incompatibilities with icloud. Do more research!
|
|
self.client.url = sanitized_url
|
|
self._calendar_home_set = CalendarSet(
|
|
self.client, self.client.url.join(sanitized_url)
|
|
)
|
|
|
|
def calendars(self) -> List["Calendar"]:
|
|
"""
|
|
Return the principal's calendars.
|
|
"""
|
|
return self.calendar_home_set.calendars()
|
|
|
|
def freebusy_request(self, dtstart, dtend, attendees):
|
|
"""Sends a freebusy-request for some attendee to the server
|
|
as per RFC6638.
|
|
"""
|
|
|
|
freebusy_ical = icalendar.Calendar()
|
|
freebusy_ical.add("prodid", "-//tobixen/python-caldav//EN")
|
|
freebusy_ical.add("version", "2.0")
|
|
freebusy_ical.add("method", "REQUEST")
|
|
uid = uuid.uuid1()
|
|
freebusy_comp = icalendar.FreeBusy()
|
|
freebusy_comp.add("uid", uid)
|
|
freebusy_comp.add("dtstamp", datetime.now())
|
|
freebusy_comp.add("dtstart", dtstart)
|
|
freebusy_comp.add("dtend", dtend)
|
|
freebusy_ical.add_component(freebusy_comp)
|
|
outbox = self.schedule_outbox()
|
|
caldavobj = FreeBusy(data=freebusy_ical, parent=outbox)
|
|
caldavobj.add_organizer()
|
|
for attendee in attendees:
|
|
caldavobj.add_attendee(attendee, no_default_parameters=True)
|
|
|
|
response = self.client.post(
|
|
outbox.url,
|
|
caldavobj.data,
|
|
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
|
)
|
|
return response.find_objects_and_props()
|
|
|
|
def calendar_user_address_set(self) -> List[Optional[str]]:
|
|
"""
|
|
defined in RFC6638
|
|
"""
|
|
_addresses: Optional[_Element] = self.get_property(
|
|
cdav.CalendarUserAddressSet(), parse_props=False
|
|
)
|
|
|
|
if _addresses is None:
|
|
raise error.NotFoundError("No calendar user addresses given from server")
|
|
|
|
assert not [x for x in _addresses if x.tag != dav.Href().tag]
|
|
addresses = list(_addresses)
|
|
## possibly the preferred attribute is iCloud-specific.
|
|
## TODO: do more research on that
|
|
addresses.sort(key=lambda x: -int(x.get("preferred", 0)))
|
|
return [x.text for x in addresses]
|
|
|
|
def schedule_inbox(self) -> "ScheduleInbox":
|
|
"""
|
|
Returns the schedule inbox, as defined in RFC6638
|
|
"""
|
|
return ScheduleInbox(principal=self)
|
|
|
|
def schedule_outbox(self) -> "ScheduleOutbox":
|
|
"""
|
|
Returns the schedule outbox, as defined in RFC6638
|
|
"""
|
|
return ScheduleOutbox(principal=self)
|
|
|
|
|
|
class Calendar(DAVObject):
|
|
"""
|
|
The `Calendar` object is used to represent a calendar collection.
|
|
Refer to the RFC for details:
|
|
https://tools.ietf.org/html/rfc4791#section-5.3.1
|
|
"""
|
|
|
|
def _create(
|
|
self, name=None, id=None, supported_calendar_component_set=None, method=None
|
|
) -> None:
|
|
"""
|
|
Create a new calendar with display name `name` in `parent`.
|
|
"""
|
|
if id is None:
|
|
id = str(uuid.uuid1())
|
|
self.id = id
|
|
|
|
if method is None:
|
|
if self.client:
|
|
supported = self.client.features.is_supported(
|
|
"create-calendar", return_type=dict
|
|
)
|
|
if supported["support"] not in ("full", "fragile", "quirk"):
|
|
raise error.MkcalendarError(
|
|
"Creation of calendars (allegedly) not supported on this server"
|
|
)
|
|
if (
|
|
supported["support"] == "quirk"
|
|
and supported["behaviour"] == "mkcol-required"
|
|
):
|
|
method = "mkcol"
|
|
else:
|
|
method = "mkcalendar"
|
|
else:
|
|
method = "mkcalendar"
|
|
|
|
path = self.parent.url.join(id + "/")
|
|
self.url = path
|
|
|
|
# TODO: mkcalendar seems to ignore the body on most servers?
|
|
# at least the name doesn't get set this way.
|
|
# zimbra gives 500 (!) if body is omitted ...
|
|
|
|
prop = dav.Prop()
|
|
if name:
|
|
display_name = dav.DisplayName(name)
|
|
prop += [
|
|
display_name,
|
|
]
|
|
if supported_calendar_component_set:
|
|
sccs = cdav.SupportedCalendarComponentSet()
|
|
for scc in supported_calendar_component_set:
|
|
sccs += cdav.Comp(scc)
|
|
prop += sccs
|
|
if method == "mkcol":
|
|
from caldav.lib.debug import printxml
|
|
|
|
prop += dav.ResourceType() + [dav.Collection(), cdav.Calendar()]
|
|
|
|
set = dav.Set() + prop
|
|
|
|
mkcol = (dav.Mkcol() if method == "mkcol" else cdav.Mkcalendar()) + set
|
|
|
|
r = self._query(
|
|
root=mkcol, query_method=method, url=path, expected_return_value=201
|
|
)
|
|
|
|
# COMPATIBILITY ISSUE
|
|
# name should already be set, but we've seen caldav servers failing
|
|
# on setting the DisplayName on calendar creation
|
|
# (DAViCal, Zimbra, ...). Doing an attempt on explicitly setting the
|
|
# display name using PROPPATCH.
|
|
if name:
|
|
try:
|
|
self.set_properties([display_name])
|
|
except Exception as e:
|
|
## TODO: investigate. Those asserts break.
|
|
try:
|
|
current_display_name = self.get_display_name()
|
|
error.assert_(current_display_name == name)
|
|
except:
|
|
log.warning(
|
|
"calendar server does not support display name on calendar? Ignoring",
|
|
exc_info=True,
|
|
)
|
|
|
|
def delete(self):
|
|
## TODO: remove quirk handling from the functional tests
|
|
## TODO: this needs test code
|
|
quirk_info = self.client.features.is_supported("delete-calendar", dict)
|
|
wipe = quirk_info["support"] in ("unsupported", "fragile")
|
|
if quirk_info["support"] == "fragile":
|
|
## Do some retries on deleting the calendar
|
|
for x in range(0, 20):
|
|
try:
|
|
super().delete()
|
|
except error.DeleteError:
|
|
pass
|
|
try:
|
|
x = self.events()
|
|
sleep(0.3)
|
|
except error.NotFoundError:
|
|
wipe = False
|
|
break
|
|
|
|
if wipe:
|
|
for x in self.search():
|
|
x.delete()
|
|
else:
|
|
super().delete()
|
|
|
|
def get_supported_components(self) -> List[Any]:
|
|
"""
|
|
returns a list of component types supported by the calendar, in
|
|
string format (typically ['VJOURNAL', 'VTODO', 'VEVENT'])
|
|
"""
|
|
if self.url is None:
|
|
raise ValueError("Unexpected value None for self.url")
|
|
|
|
props = [cdav.SupportedCalendarComponentSet()]
|
|
response = self.get_properties(props, parse_response_xml=False)
|
|
response_list = response.find_objects_and_props()
|
|
prop = response_list[unquote(self.url.path)][
|
|
cdav.SupportedCalendarComponentSet().tag
|
|
]
|
|
return [supported.get("name") for supported in prop]
|
|
|
|
def save_with_invites(self, ical: str, attendees, **attendeeoptions) -> None:
|
|
"""
|
|
sends a schedule request to the server. Equivalent with save_event, save_todo, etc,
|
|
but the attendees will be added to the ical object before sending it to the server.
|
|
"""
|
|
## TODO: consolidate together with save_*
|
|
obj = self._calendar_comp_class_by_data(ical)(data=ical, client=self.client)
|
|
obj.parent = self
|
|
obj.add_organizer()
|
|
for attendee in attendees:
|
|
obj.add_attendee(attendee, **attendeeoptions)
|
|
obj.id = obj.icalendar_instance.walk("vevent")[0]["uid"]
|
|
obj.save()
|
|
return obj
|
|
|
|
def _use_or_create_ics(self, ical, objtype, **ical_data):
|
|
if ical_data or (
|
|
(isinstance(ical, str) or isinstance(ical, bytes))
|
|
and b"BEGIN:VCALENDAR" not in to_wire(ical)
|
|
):
|
|
## TODO: the ical_fragment code is not much tested
|
|
if ical and "ical_fragment" not in ical_data:
|
|
ical_data["ical_fragment"] = ical
|
|
return vcal.create_ical(objtype=objtype, **ical_data)
|
|
return ical
|
|
|
|
def save_object(
|
|
self,
|
|
## TODO: this should be made optional. The class may be given in the ical object.
|
|
## TODO: also, accept a string.
|
|
objclass: Type[DAVObject],
|
|
## TODO: ical may also be a vobject or icalendar instance
|
|
ical: Optional[str] = None,
|
|
no_overwrite: bool = False,
|
|
no_create: bool = False,
|
|
**ical_data,
|
|
) -> "CalendarResourceObject":
|
|
"""Add a new event to the calendar, with the given ical.
|
|
|
|
Args:
|
|
objclass: Event, Journal or Todo
|
|
ical: ical object (text, icalendar or vobject instance)
|
|
no_overwrite: existing calendar objects should not be overwritten
|
|
no_create: don't create a new object, existing calendar objects should be updated
|
|
dt_start: properties to be inserted into the icalendar object
|
|
, dt_end: properties to be inserted into the icalendar object
|
|
summary: properties to be inserted into the icalendar object
|
|
alarm_trigger: when given, one alarm will be added
|
|
alarm_action: when given, one alarm will be added
|
|
alarm_attach: when given, one alarm will be added
|
|
|
|
Note that the list of parameters going into the icalendar
|
|
object and alamrs is not complete. Refer to the RFC or the
|
|
icalendar library for a full list of properties.
|
|
"""
|
|
o = objclass(
|
|
self.client,
|
|
data=self._use_or_create_ics(
|
|
ical, objtype=f"V{objclass.__name__.upper()}", **ical_data
|
|
),
|
|
parent=self,
|
|
)
|
|
o = o.save(no_overwrite=no_overwrite, no_create=no_create)
|
|
## TODO: Saving nothing is currently giving an object with None as URL.
|
|
## This should probably be changed in some future version to raise an error
|
|
## See also CalendarObjectResource.save()
|
|
if o.url is not None:
|
|
o._handle_reverse_relations(fix=True)
|
|
return o
|
|
|
|
## TODO: maybe we should deprecate those three
|
|
def save_event(self, *largs, **kwargs) -> "Event":
|
|
"""
|
|
Returns ``self.save_object(Event, ...)`` - see :class:`save_object`
|
|
"""
|
|
return self.save_object(Event, *largs, **kwargs)
|
|
|
|
def save_todo(self, *largs, **kwargs) -> "Todo":
|
|
"""
|
|
Returns ``self.save_object(Todo, ...)`` - so see :class:`save_object`
|
|
"""
|
|
return self.save_object(Todo, *largs, **kwargs)
|
|
|
|
def save_journal(self, *largs, **kwargs) -> "Journal":
|
|
"""
|
|
Returns ``self.save_object(Journal, ...)`` - so see :class:`save_object`
|
|
"""
|
|
return self.save_object(Journal, *largs, **kwargs)
|
|
|
|
## legacy aliases
|
|
## TODO: should be deprecated
|
|
|
|
## TODO: think more through this - is `save_foo` better than `add_foo`?
|
|
## `save_foo` should not be used for updating existing content on the
|
|
## calendar!
|
|
add_object = save_object
|
|
add_event = save_event
|
|
add_todo = save_todo
|
|
add_journal = save_journal
|
|
|
|
def save(self, method=None):
|
|
"""
|
|
The save method for a calendar is only used to create it, for now.
|
|
We know we have to create it when we don't have a url.
|
|
|
|
Returns:
|
|
* self
|
|
"""
|
|
if self.url is None:
|
|
self._create(
|
|
id=self.id, name=self.name, method=method, **self.extra_init_options
|
|
)
|
|
return self
|
|
|
|
# def data2object_class
|
|
|
|
def _multiget(
|
|
self, event_urls: Iterable[URL], raise_notfound: bool = False
|
|
) -> Iterable[str]:
|
|
"""
|
|
get multiple events' data.
|
|
TODO: Does it overlap the _request_report_build_resultlist method
|
|
"""
|
|
if self.url is None:
|
|
raise ValueError("Unexpected value None for self.url")
|
|
|
|
rv = []
|
|
prop = dav.Prop() + cdav.CalendarData()
|
|
root = (
|
|
cdav.CalendarMultiGet()
|
|
+ prop
|
|
+ [dav.Href(value=u.path) for u in event_urls]
|
|
)
|
|
response = self._query(root, 1, "report")
|
|
results = response.expand_simple_props([cdav.CalendarData()])
|
|
if raise_notfound:
|
|
for href in response.statuses:
|
|
status = response.statuses[href]
|
|
if status and "404" in status:
|
|
raise error.NotFoundError(f"Status {status} in {href}")
|
|
for r in results:
|
|
yield (r, results[r][cdav.CalendarData.tag])
|
|
|
|
## Replace the last lines with
|
|
def multiget(
|
|
self, event_urls: Iterable[URL], raise_notfound: bool = False
|
|
) -> Iterable[_CC]:
|
|
"""
|
|
get multiple events' data
|
|
TODO: Does it overlap the _request_report_build_resultlist method?
|
|
@author mtorange@gmail.com (refactored by Tobias)
|
|
"""
|
|
results = self._multiget(event_urls, raise_notfound=raise_notfound)
|
|
for url, data in results:
|
|
yield self._calendar_comp_class_by_data(data)(
|
|
self.client,
|
|
url=self.url.join(url),
|
|
data=data,
|
|
parent=self,
|
|
)
|
|
|
|
def calendar_multiget(self, *largs, **kwargs):
|
|
"""
|
|
get multiple events' data
|
|
@author mtorange@gmail.com
|
|
(refactored by Tobias)
|
|
This is for backward compatibility. It may be removed in 3.0 or later release.
|
|
"""
|
|
return list(self.multiget(*largs, **kwargs))
|
|
|
|
def date_search(
|
|
self,
|
|
start: datetime,
|
|
end: Optional[datetime] = None,
|
|
compfilter: None = "VEVENT",
|
|
expand: Union[bool, Literal["maybe"]] = "maybe",
|
|
verify_expand: bool = False,
|
|
) -> Sequence["CalendarObjectResource"]:
|
|
# type (TimeStamp, TimeStamp, str, str) -> CalendarObjectResource
|
|
"""Deprecated. Use self.search() instead.
|
|
|
|
Search events by date in the calendar.
|
|
|
|
Args
|
|
start : defaults to datetime.today().
|
|
end : same as above.
|
|
compfilter : defaults to events only. Set to None to fetch all calendar components.
|
|
expand : should recurrent events be expanded? (to preserve backward-compatibility the default "maybe" will be changed into True unless the date_search is open-ended)
|
|
verify_expand : not in use anymore, but kept for backward compatibility
|
|
|
|
Returns:
|
|
* [CalendarObjectResource(), ...]
|
|
|
|
Recurring events are expanded if they are occurring during the
|
|
specified time frame and if an end timestamp is given.
|
|
|
|
Note that this is a deprecated method. The `search` method is
|
|
nearly equivalent. Differences: default for ``compfilter`` is
|
|
to search for all objects, default for ``expand`` is
|
|
``False``, and it has a different default
|
|
``split_expanded=True``.
|
|
"""
|
|
## date_search will probably disappear in 3.0
|
|
warnings.warn(
|
|
"use `calendar.search rather than `calendar.date_search`",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
|
|
if verify_expand:
|
|
logging.warning(
|
|
"verify_expand in date_search does not work anymore, as we're doing client side expansion instead"
|
|
)
|
|
|
|
## for backward compatibility - expand should be false
|
|
## in an open-ended date search, otherwise true
|
|
if expand == "maybe":
|
|
expand = end
|
|
|
|
if compfilter == "VEVENT":
|
|
comp_class = Event
|
|
elif compfilter == "VTODO":
|
|
comp_class = Todo
|
|
else:
|
|
comp_class = None
|
|
|
|
objects = self.search(
|
|
start=start,
|
|
end=end,
|
|
comp_class=comp_class,
|
|
expand=expand,
|
|
split_expanded=False,
|
|
)
|
|
|
|
return objects
|
|
|
|
## TODO: this logic has been partly duplicated in calendar_multiget, but
|
|
## the code there is much more readable and condensed than this.
|
|
## Can code below be refactored?
|
|
def _request_report_build_resultlist(
|
|
self, xml, comp_class=None, props=None, no_calendardata=False
|
|
):
|
|
"""
|
|
Takes some input XML, does a report query on a calendar object
|
|
and returns the resource objects found.
|
|
"""
|
|
matches = []
|
|
if props is None:
|
|
props_ = [cdav.CalendarData()]
|
|
else:
|
|
props_ = [cdav.CalendarData()] + props
|
|
response = self._query(xml, 1, "report")
|
|
results = response.expand_simple_props(props_)
|
|
for r in results:
|
|
pdata = results[r]
|
|
if cdav.CalendarData.tag in pdata:
|
|
cdata = pdata.pop(cdav.CalendarData.tag)
|
|
comp_class_ = (
|
|
self._calendar_comp_class_by_data(cdata)
|
|
if comp_class is None
|
|
else comp_class
|
|
)
|
|
else:
|
|
cdata = None
|
|
if comp_class_ is None:
|
|
## no CalendarData fetched - which is normal i.e. when doing a sync-token report and only asking for the URLs
|
|
comp_class_ = CalendarObjectResource
|
|
url = URL(r)
|
|
if url.hostname is None:
|
|
# Quote when result is not a full URL
|
|
url = quote(r)
|
|
## icloud hack - icloud returns the calendar URL as well as the calendar item URLs
|
|
if self.url.join(url) == self.url:
|
|
continue
|
|
matches.append(
|
|
comp_class_(
|
|
self.client,
|
|
url=self.url.join(url),
|
|
data=cdata,
|
|
parent=self,
|
|
props=pdata,
|
|
)
|
|
)
|
|
return (response, matches)
|
|
|
|
def search(
|
|
self,
|
|
xml: str = None,
|
|
server_expand: bool = False,
|
|
split_expanded: bool = True,
|
|
sort_reverse: bool = False,
|
|
props: Optional[List[cdav.CalendarData]] = None,
|
|
filters=None,
|
|
post_filter=None,
|
|
_hacks=None,
|
|
**searchargs,
|
|
) -> List[_CC]:
|
|
"""Sends a search request towards the server, processes the
|
|
results if needed and returns the objects found.
|
|
|
|
Refactoring 2025-11: a new class
|
|
class:`caldav.search.CalDAVSearcher` has been made, and
|
|
this method is sort of a wrapper for
|
|
CalDAVSearcher.search, ensuring backward
|
|
compatibility. The documentation may be slightly overlapping.
|
|
|
|
I believe that for simple tasks, this method will be easier to
|
|
use than the new interface, hence there are no plans for the
|
|
foreseeable future to deprecate it. This search method will
|
|
continue working as it has been doing before for all
|
|
foreseeable future. I believe that for simple tasks, this
|
|
method will be easier to use than to construct a
|
|
CalDAVSearcher object and do searches from there. The
|
|
refactoring was made necessary because the parameter list to
|
|
`search` was becoming unmanagable. Advanced searches should
|
|
be done via the new interface.
|
|
|
|
Caveat: The searching is done on the server side, the RFC is
|
|
not very crystal clear on many of the corner cases, and
|
|
servers often behave differently when presented with a search
|
|
request. There is planned work to work around server
|
|
incompatibilities on the client side, but as for now
|
|
complicated searches will give different results on different
|
|
servers.
|
|
|
|
``todo`` - searches explicitly for todo. Unless
|
|
``include_completed`` is specified, there is some special
|
|
logic ensuring only pending tasks is returned.
|
|
|
|
There is corresponding ``event`` and ``journal`` bools to
|
|
specify that the search should be only for events or journals.
|
|
When neither are set, one should expect to get all objects
|
|
returned - but quite some calendar servers will return
|
|
nothing. This will be solved client-side in the future, as
|
|
for 2.0 it's recommended to search separately for tasks,
|
|
events and journals to ensure consistent behaviour across
|
|
different calendar servers and providers.
|
|
|
|
``sort_keys`` refers to (case-insensitive) properties in the
|
|
icalendar object, ``sort_reverse`` can also be given. The
|
|
sorting will be done client-side.
|
|
|
|
Use ``start`` and ``end`` for time-range searches. Open-ended
|
|
searches are supported (i.e. "everything in the future"), but
|
|
it's recommended to use closed ranges (i.e. have an "event
|
|
horizon" of a year and ask for "everything from now and one
|
|
year ahead") and get the data expanded.
|
|
|
|
With the boolean ``expand`` set, you don't have to think too
|
|
much about recurrences - they will be expanded, and with the
|
|
(default) ``split_expanded`` set, each recurrence will be
|
|
returned as a separate list object (otherwise all recurrences
|
|
will be put into one ``VCALENDAR`` and returned as one
|
|
``Event``). This makes it safe to use the ``event.component``
|
|
property. The non-expanded resultset may include events where
|
|
the timespan doesn't match the date interval you searched for,
|
|
as well as items with multiple components ("special"
|
|
recurrences), meaning you may need logic on the client side to
|
|
handle the recurrences. *Only time range searches over closed
|
|
time intervals may be expanded*.
|
|
|
|
As for 2.0, the expand-logic is by default done on the
|
|
client-side, for consistent results across various server
|
|
incompabilities. However, you may force server-side expansion
|
|
by setting ``server_expand=True``
|
|
|
|
Text attribute search parameters can be given to query the
|
|
"properties" in the calendar data: category, uid, summary,
|
|
comment, description, location, status. According to the RFC,
|
|
a substring search should be done.
|
|
|
|
You may use no_category, no_summary, etc to search for objects
|
|
that are missing those attributes.
|
|
|
|
Negated text matches are not supported yet.
|
|
|
|
For power-users, those parameters are also supported:
|
|
|
|
* ``xml`` - use this search query, and ignore other filter parameters
|
|
* ``comp_class`` - alternative to the ``event``, ``todo`` or ``journal`` booleans described above.
|
|
* ``filters`` - other kind of filters (in lxml tree format)
|
|
|
|
"""
|
|
## Late import to avoid cyclic imports
|
|
from .search import CalDAVSearcher
|
|
|
|
## This is basically a wrapper for CalDAVSearcher.search
|
|
## The logic below will massage the parameters in ``searchargs``
|
|
## and put them into the CalDAVSearcher object.
|
|
|
|
if searchargs.get("expand", True) not in (True, False):
|
|
warnings.warn(
|
|
"in cal.search(), expand should be a bool",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
if searchargs["expand"] == "client":
|
|
searchargs["expand"] = True
|
|
if searchargs["expand"] == "server":
|
|
server_expand = True
|
|
searchargs["expand"] = False
|
|
|
|
## Transfer all the arguments to CalDAVSearcher
|
|
my_searcher = CalDAVSearcher()
|
|
for key in searchargs:
|
|
assert key[0] != "_" ## not allowed
|
|
alias = key
|
|
if key == "class_": ## because class is a reserved word
|
|
alias = "class"
|
|
if key == "no_category":
|
|
alias = "no_categories"
|
|
if key == "no_class_":
|
|
alias = "no_class"
|
|
if key == "sort_keys":
|
|
if isinstance(searchargs["sort_keys"], str):
|
|
searchargs["sort_keys"] = [searchargs["sort_keys"]]
|
|
for sortkey in searchargs["sort_keys"]:
|
|
my_searcher.add_sort_key(sortkey, sort_reverse)
|
|
continue
|
|
elif key == "comp_class" or key in my_searcher.__dataclass_fields__:
|
|
setattr(my_searcher, key, searchargs[key])
|
|
continue
|
|
elif alias.startswith("no_"):
|
|
my_searcher.add_property_filter(
|
|
alias[3:], searchargs[key], operator="undef"
|
|
)
|
|
else:
|
|
my_searcher.add_property_filter(alias, searchargs[key])
|
|
|
|
if not xml and filters:
|
|
xml = filters
|
|
|
|
return my_searcher.search(
|
|
self, server_expand, split_expanded, props, xml, post_filter, _hacks
|
|
)
|
|
|
|
def freebusy_request(self, start: datetime, end: datetime) -> "FreeBusy":
|
|
"""
|
|
Search the calendar, but return only the free/busy information.
|
|
|
|
Args:
|
|
start : defaults to datetime.today().
|
|
end : same as above.
|
|
|
|
Returns:
|
|
[FreeBusy(), ...]
|
|
"""
|
|
|
|
root = cdav.FreeBusyQuery() + [cdav.TimeRange(start, end)]
|
|
response = self._query(root, 1, "report")
|
|
return FreeBusy(self, response.raw)
|
|
|
|
def todos(
|
|
self,
|
|
sort_keys: Sequence[str] = ("due", "priority"),
|
|
include_completed: bool = False,
|
|
sort_key: Optional[str] = None,
|
|
) -> List["Todo"]:
|
|
"""
|
|
Fetches a list of todo events (this is a wrapper around search).
|
|
|
|
Args:
|
|
sort_keys: use this field in the VTODO for sorting (iterable of lower case string, i.e. ('priority','due')).
|
|
include_completed: boolean - by default, only pending tasks are listed
|
|
sort_key: DEPRECATED, for backwards compatibility with version 0.4.
|
|
"""
|
|
if sort_key:
|
|
sort_keys = (sort_key,)
|
|
|
|
return self.search(
|
|
todo=True, include_completed=include_completed, sort_keys=sort_keys
|
|
)
|
|
|
|
def _calendar_comp_class_by_data(self, data):
|
|
"""
|
|
takes some data, either as icalendar text or icalender object (TODO:
|
|
consider vobject) and returns the appropriate
|
|
CalendarResourceObject child class.
|
|
"""
|
|
if data is None:
|
|
## no data received - we'd need to load it before we can know what
|
|
## class it really is. Assign the base class as for now.
|
|
return CalendarObjectResource
|
|
if hasattr(data, "split"):
|
|
for line in data.split("\n"):
|
|
line = line.strip()
|
|
if line == "BEGIN:VEVENT":
|
|
return Event
|
|
if line == "BEGIN:VTODO":
|
|
return Todo
|
|
if line == "BEGIN:VJOURNAL":
|
|
return Journal
|
|
if line == "BEGIN:VFREEBUSY":
|
|
return FreeBusy
|
|
elif hasattr(data, "subcomponents"):
|
|
if not len(data.subcomponents):
|
|
return CalendarObjectResource
|
|
|
|
ical2caldav = {
|
|
icalendar.Event: Event,
|
|
icalendar.Todo: Todo,
|
|
icalendar.Journal: Journal,
|
|
icalendar.FreeBusy: FreeBusy,
|
|
}
|
|
for sc in data.subcomponents:
|
|
if sc.__class__ in ical2caldav:
|
|
return ical2caldav[sc.__class__]
|
|
return CalendarObjectResource
|
|
|
|
def event_by_url(self, href, data: Optional[Any] = None) -> "Event":
|
|
"""
|
|
Returns the event with the given URL.
|
|
"""
|
|
return Event(url=href, data=data, parent=self).load()
|
|
|
|
def object_by_uid(
|
|
self,
|
|
uid: str,
|
|
comp_filter: Optional[cdav.CompFilter] = None,
|
|
comp_class: Optional["CalendarObjectResource"] = None,
|
|
) -> "Event":
|
|
"""
|
|
Get one event from the calendar.
|
|
|
|
Args:
|
|
uid: the event uid
|
|
comp_class: filter by component type (Event, Todo, Journal)
|
|
comp_filter: for backward compatibility. Don't use!
|
|
|
|
Returns:
|
|
Event() or None
|
|
"""
|
|
## late import to avoid cyclic dependencies
|
|
from .search import CalDAVSearcher
|
|
|
|
## 2025-11: some logic validating the comp_filter and
|
|
## comp_class has been removed, and replaced with the
|
|
## recommendation not to use comp_filter. We're still using
|
|
## comp_filter internally, but it's OK, it doesn't need to be
|
|
## validated.
|
|
|
|
## Lots of old logic has been removed, the new search logic
|
|
## can do the things for us:
|
|
searcher = CalDAVSearcher(comp_class=comp_class)
|
|
## Default is substring
|
|
searcher.add_property_filter("uid", uid, "==")
|
|
items_found = searcher.search(
|
|
self, xml=comp_filter, _hacks="insist", post_filter=True
|
|
)
|
|
|
|
if not items_found:
|
|
raise error.NotFoundError("%s not found on server" % uid)
|
|
error.assert_(len(items_found) == 1)
|
|
return items_found[0]
|
|
|
|
def todo_by_uid(self, uid: str) -> "CalendarObjectResource":
|
|
"""
|
|
Returns the task with the given uid (wraps around :class:`object_by_uid`)
|
|
"""
|
|
return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VTODO"))
|
|
|
|
def event_by_uid(self, uid: str) -> "CalendarObjectResource":
|
|
"""
|
|
Returns the event with the given uid (wraps around :class:`object_by_uid`)
|
|
"""
|
|
return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VEVENT"))
|
|
|
|
def journal_by_uid(self, uid: str) -> "CalendarObjectResource":
|
|
"""
|
|
Returns the journal with the given uid (wraps around :class:`object_by_uid`)
|
|
"""
|
|
return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VJOURNAL"))
|
|
|
|
# alias for backward compatibility
|
|
event = event_by_uid
|
|
|
|
def events(self) -> List["Event"]:
|
|
"""
|
|
List all events from the calendar.
|
|
|
|
Returns:
|
|
* [Event(), ...]
|
|
"""
|
|
return self.search(comp_class=Event)
|
|
|
|
def _generate_fake_sync_token(self, objects: List["CalendarObjectResource"]) -> str:
|
|
"""
|
|
Generate a fake sync token for servers without sync support.
|
|
Uses a hash of all ETags to detect changes.
|
|
|
|
Args:
|
|
objects: List of calendar objects to generate token from
|
|
|
|
Returns:
|
|
A fake sync token string
|
|
"""
|
|
import hashlib
|
|
|
|
etags = []
|
|
for obj in objects:
|
|
if hasattr(obj, "props") and dav.GetEtag.tag in obj.props:
|
|
etags.append(str(obj.props[dav.GetEtag.tag]))
|
|
elif hasattr(obj, "url"):
|
|
## If no etag, use URL as fallback identifier
|
|
etags.append(str(obj.url.canonical()))
|
|
etags.sort() ## Consistent ordering
|
|
combined = "|".join(etags)
|
|
hash_value = hashlib.md5(combined.encode()).hexdigest()
|
|
return f"fake-{hash_value}"
|
|
|
|
def objects_by_sync_token(
|
|
self,
|
|
sync_token: Optional[Any] = None,
|
|
load_objects: bool = False,
|
|
disable_fallback: bool = False,
|
|
) -> "SynchronizableCalendarObjectCollection":
|
|
"""objects_by_sync_token aka objects
|
|
|
|
Do a sync-collection report, ref RFC 6578 and
|
|
https://github.com/python-caldav/caldav/issues/87
|
|
|
|
This method will return all objects in the calendar if no
|
|
sync_token is passed (the method should then be referred to as
|
|
"objects"), or if the sync_token is unknown to the server. If
|
|
a sync-token known by the server is passed, it will return
|
|
objects that are added, deleted or modified since last time
|
|
the sync-token was set.
|
|
|
|
If load_objects is set to True, the objects will be loaded -
|
|
otherwise empty CalendarObjectResource objects will be returned.
|
|
|
|
This method will return a SynchronizableCalendarObjectCollection object, which is
|
|
an iterable.
|
|
|
|
This method transparently falls back to retrieving all objects if the server
|
|
doesn't support sync tokens. The fallback behavior is identical from the user's
|
|
perspective, but less efficient as it transfers the entire calendar on each sync.
|
|
|
|
If disable_fallback is set to True, the method will raise an exception instead
|
|
of falling back to retrieving all objects. This is useful for testing whether
|
|
the server truly supports sync tokens.
|
|
"""
|
|
## Check if we should attempt to use sync tokens
|
|
## (either server supports them, or we haven't checked yet, or this is a fake token)
|
|
use_sync_token = True
|
|
sync_support = self.client.features.is_supported("sync-token", return_type=dict)
|
|
if sync_support.get("support") == "unsupported":
|
|
if disable_fallback:
|
|
raise error.ReportError("Sync tokens are not supported by the server")
|
|
use_sync_token = False
|
|
## If sync_token looks like a fake token, don't try real sync-collection
|
|
if (
|
|
sync_token
|
|
and isinstance(sync_token, str)
|
|
and sync_token.startswith("fake-")
|
|
):
|
|
use_sync_token = False
|
|
|
|
if use_sync_token:
|
|
try:
|
|
cmd = dav.SyncCollection()
|
|
token = dav.SyncToken(value=sync_token)
|
|
level = dav.SyncLevel(value="1")
|
|
props = dav.Prop() + dav.GetEtag()
|
|
root = cmd + [level, token, props]
|
|
(response, objects) = self._request_report_build_resultlist(
|
|
root, props=[dav.GetEtag()], no_calendardata=True
|
|
)
|
|
## TODO: look more into this, I think sync_token should be directly available through response object
|
|
try:
|
|
sync_token = response.sync_token
|
|
except:
|
|
sync_token = response.tree.findall(".//" + dav.SyncToken.tag)[
|
|
0
|
|
].text
|
|
|
|
## this is not quite right - the etag we've fetched can already be outdated
|
|
if load_objects:
|
|
for obj in objects:
|
|
try:
|
|
obj.load()
|
|
except error.NotFoundError:
|
|
## The object was deleted
|
|
pass
|
|
return SynchronizableCalendarObjectCollection(
|
|
calendar=self, objects=objects, sync_token=sync_token
|
|
)
|
|
except (error.ReportError, error.DAVError) as e:
|
|
## Server doesn't support sync tokens or the sync-collection REPORT failed
|
|
if disable_fallback:
|
|
raise
|
|
log.info(
|
|
f"Sync-collection REPORT failed ({e}), falling back to full retrieval"
|
|
)
|
|
## Fall through to fallback implementation
|
|
|
|
## FALLBACK: Server doesn't support sync tokens
|
|
## Retrieve all objects and emulate sync token behavior
|
|
log.debug("Using fallback sync mechanism (retrieving all objects)")
|
|
|
|
## Use search() to get all objects. search() will include CalendarData by default.
|
|
## We can't avoid this in the fallback mechanism without significant refactoring.
|
|
all_objects = list(self.search())
|
|
|
|
## Load objects if requested (objects may already have data from search)
|
|
if load_objects:
|
|
for obj in all_objects:
|
|
## Only load if not already loaded
|
|
if not hasattr(obj, "_data") or obj._data is None:
|
|
try:
|
|
obj.load()
|
|
except error.NotFoundError:
|
|
pass
|
|
|
|
## Fetch ETags for all objects if not already present
|
|
## ETags are crucial for detecting changes in the fallback mechanism
|
|
if all_objects and (
|
|
not hasattr(all_objects[0], "props")
|
|
or dav.GetEtag.tag not in all_objects[0].props
|
|
):
|
|
## Use PROPFIND to fetch ETags for all objects
|
|
try:
|
|
## Do a depth-1 PROPFIND on the calendar to get all ETags
|
|
response = self._query_properties([dav.GetEtag()], depth=1)
|
|
etag_props = response.expand_simple_props([dav.GetEtag()])
|
|
|
|
## Map ETags to objects by URL (using string keys for reliable comparison)
|
|
url_to_obj = {str(obj.url.canonical()): obj for obj in all_objects}
|
|
log.debug(f"Fallback: Fetching ETags for {len(url_to_obj)} objects")
|
|
for url_str, props in etag_props.items():
|
|
canonical_url_str = str(self.url.join(url_str).canonical())
|
|
if canonical_url_str in url_to_obj:
|
|
if not hasattr(url_to_obj[canonical_url_str], "props"):
|
|
url_to_obj[canonical_url_str].props = {}
|
|
url_to_obj[canonical_url_str].props.update(props)
|
|
log.debug(f"Fallback: Added ETag to {canonical_url_str}")
|
|
except Exception as e:
|
|
## If fetching ETags fails, we'll fall back to URL-based tokens
|
|
## which can't detect content changes, only additions/deletions
|
|
log.debug(f"Failed to fetch ETags for fallback sync: {e}")
|
|
pass
|
|
|
|
## Generate a fake sync token based on current state
|
|
fake_sync_token = self._generate_fake_sync_token(all_objects)
|
|
|
|
## If a sync_token was provided, check if anything has changed
|
|
if (
|
|
sync_token
|
|
and isinstance(sync_token, str)
|
|
and sync_token.startswith("fake-")
|
|
):
|
|
## Compare the provided token with the new token
|
|
if sync_token == fake_sync_token:
|
|
## Nothing has changed, return empty collection
|
|
return SynchronizableCalendarObjectCollection(
|
|
calendar=self, objects=[], sync_token=fake_sync_token
|
|
)
|
|
## If tokens differ, return all objects (emulating a full sync)
|
|
## In a real implementation, we'd return only changed objects,
|
|
## but that requires storing previous state which we don't have
|
|
|
|
return SynchronizableCalendarObjectCollection(
|
|
calendar=self, objects=all_objects, sync_token=fake_sync_token
|
|
)
|
|
|
|
objects = objects_by_sync_token
|
|
|
|
def journals(self) -> List["Journal"]:
|
|
"""
|
|
List all journals from the calendar.
|
|
|
|
Returns:
|
|
* [Journal(), ...]
|
|
"""
|
|
return self.search(comp_class=Journal)
|
|
|
|
|
|
class ScheduleMailbox(Calendar):
|
|
"""
|
|
RFC6638 defines an inbox and an outbox for handling event scheduling.
|
|
|
|
TODO: As ScheduleMailboxes works a bit like calendars, I've chosen
|
|
to inheritate the Calendar class, but this is a bit incorrect, a
|
|
ScheduleMailbox is a collection, but not really a calendar. We
|
|
should create a common base class for ScheduleMailbox and Calendar
|
|
eventually.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
client: Optional["DAVClient"] = None,
|
|
principal: Optional[Principal] = None,
|
|
url: Union[str, ParseResult, SplitResult, URL, None] = None,
|
|
) -> None:
|
|
"""
|
|
Will locate the mbox if no url is given
|
|
"""
|
|
super(ScheduleMailbox, self).__init__(client=client, url=url)
|
|
self._items = None
|
|
if not client and principal:
|
|
self.client = principal.client
|
|
if not principal and client:
|
|
if self.client is None:
|
|
raise ValueError("Unexpected value None for self.client")
|
|
|
|
principal = self.client.principal
|
|
if url is not None:
|
|
if client is None:
|
|
raise ValueError("Unexpected value None for client")
|
|
|
|
self.url = client.url.join(URL.objectify(url))
|
|
else:
|
|
if principal is None:
|
|
raise ValueError("Unexpected value None for principal")
|
|
|
|
if self.client is None:
|
|
raise ValueError("Unexpected value None for self.client")
|
|
|
|
self.url = principal.url
|
|
try:
|
|
# we ignore the type here as this is defined in sub-classes only; require more changes to
|
|
# properly fix in a future revision
|
|
self.url = self.client.url.join(URL(self.get_property(self.findprop()))) # type: ignore
|
|
except:
|
|
logging.error("something bad happened", exc_info=True)
|
|
error.assert_(self.client.check_scheduling_support())
|
|
self.url = None
|
|
# we ignore the type here as this is defined in sub-classes only; require more changes to
|
|
# properly fix in a future revision
|
|
raise error.NotFoundError(
|
|
"principal has no %s. %s"
|
|
% (str(self.findprop()), error.ERR_FRAGMENT) # type: ignore
|
|
)
|
|
|
|
def get_items(self):
|
|
"""
|
|
TODO: work in progress
|
|
TODO: perhaps this belongs to the super class?
|
|
"""
|
|
if not self._items:
|
|
try:
|
|
self._items = self.objects(load_objects=True)
|
|
except:
|
|
logging.debug(
|
|
"caldav server does not seem to support a sync-token REPORT query on a scheduling mailbox"
|
|
)
|
|
error.assert_("google" in str(self.url))
|
|
self._items = [
|
|
CalendarObjectResource(url=x[0], client=self.client)
|
|
for x in self.children()
|
|
]
|
|
for x in self._items:
|
|
x.load()
|
|
else:
|
|
try:
|
|
self._items.sync()
|
|
except:
|
|
self._items = [
|
|
CalendarObjectResource(url=x[0], client=self.client)
|
|
for x in self.children()
|
|
]
|
|
for x in self._items:
|
|
x.load()
|
|
return self._items
|
|
|
|
## TODO: work in progress
|
|
|
|
|
|
# def get_invites():
|
|
# for item in self.get_items():
|
|
# if item.vobject_instance.vevent.
|
|
|
|
|
|
class ScheduleInbox(ScheduleMailbox):
|
|
findprop = cdav.ScheduleInboxURL
|
|
|
|
|
|
class ScheduleOutbox(ScheduleMailbox):
|
|
findprop = cdav.ScheduleOutboxURL
|
|
|
|
|
|
class SynchronizableCalendarObjectCollection:
|
|
"""
|
|
This class may hold a cached snapshot of a calendar, and changes
|
|
in the calendar can easily be copied over through the sync method.
|
|
|
|
To create a SynchronizableCalendarObjectCollection object, use
|
|
calendar.objects(load_objects=True)
|
|
"""
|
|
|
|
def __init__(self, calendar, objects, sync_token) -> None:
|
|
self.calendar = calendar
|
|
self.sync_token = sync_token
|
|
self.objects = objects
|
|
self._objects_by_url = None
|
|
|
|
def __iter__(self) -> Iterator[Any]:
|
|
return self.objects.__iter__()
|
|
|
|
def __len__(self) -> int:
|
|
return len(self.objects)
|
|
|
|
def objects_by_url(self):
|
|
"""
|
|
returns a dict of the contents of the SynchronizableCalendarObjectCollection, URLs -> objects.
|
|
"""
|
|
if self._objects_by_url is None:
|
|
self._objects_by_url = {}
|
|
for obj in self:
|
|
self._objects_by_url[obj.url.canonical()] = obj
|
|
return self._objects_by_url
|
|
|
|
def sync(self) -> Tuple[Any, Any]:
|
|
"""
|
|
This method will contact the caldav server,
|
|
request all changes from it, and sync up the collection.
|
|
|
|
This method transparently falls back to comparing full calendar state
|
|
if the server doesn't support sync tokens.
|
|
"""
|
|
updated_objs = []
|
|
deleted_objs = []
|
|
|
|
## Check if we're using fake sync tokens (fallback mode)
|
|
is_fake_token = isinstance(self.sync_token, str) and self.sync_token.startswith(
|
|
"fake-"
|
|
)
|
|
|
|
if not is_fake_token:
|
|
## Try to use real sync tokens
|
|
try:
|
|
updates = self.calendar.objects_by_sync_token(
|
|
self.sync_token, load_objects=False
|
|
)
|
|
|
|
## If we got a fake token back, we've fallen back
|
|
if isinstance(
|
|
updates.sync_token, str
|
|
) and updates.sync_token.startswith("fake-"):
|
|
is_fake_token = True
|
|
else:
|
|
## Real sync token path
|
|
obu = self.objects_by_url()
|
|
for obj in updates:
|
|
obj.url = obj.url.canonical()
|
|
if (
|
|
obj.url in obu
|
|
and dav.GetEtag.tag in obu[obj.url].props
|
|
and dav.GetEtag.tag in obj.props
|
|
):
|
|
if (
|
|
obu[obj.url].props[dav.GetEtag.tag]
|
|
== obj.props[dav.GetEtag.tag]
|
|
):
|
|
continue
|
|
obu[obj.url] = obj
|
|
try:
|
|
obj.load()
|
|
updated_objs.append(obj)
|
|
except error.NotFoundError:
|
|
deleted_objs.append(obj)
|
|
obu.pop(obj.url)
|
|
|
|
self.objects = list(obu.values())
|
|
self._objects_by_url = None ## Invalidate cache
|
|
self.sync_token = updates.sync_token
|
|
return (updated_objs, deleted_objs)
|
|
except (error.ReportError, error.DAVError):
|
|
## Sync failed, fall back
|
|
is_fake_token = True
|
|
|
|
if is_fake_token:
|
|
## FALLBACK: Compare full calendar state
|
|
log.debug("Using fallback sync mechanism (comparing all objects)")
|
|
|
|
## Retrieve all current objects from server
|
|
current_objects = list(self.calendar.search())
|
|
|
|
## Load them
|
|
for obj in current_objects:
|
|
try:
|
|
obj.load()
|
|
except error.NotFoundError:
|
|
pass
|
|
|
|
## Build URL-indexed dicts for comparison
|
|
current_by_url = {obj.url.canonical(): obj for obj in current_objects}
|
|
old_by_url = self.objects_by_url()
|
|
|
|
## Find updated and new objects
|
|
for url, obj in current_by_url.items():
|
|
if url in old_by_url:
|
|
## Object exists in both - check if modified
|
|
## Compare data if available, otherwise consider it unchanged
|
|
old_data = (
|
|
old_by_url[url].data
|
|
if hasattr(old_by_url[url], "data")
|
|
else None
|
|
)
|
|
new_data = obj.data if hasattr(obj, "data") else None
|
|
if old_data != new_data and new_data is not None:
|
|
updated_objs.append(obj)
|
|
else:
|
|
## New object
|
|
updated_objs.append(obj)
|
|
|
|
## Find deleted objects
|
|
for url in old_by_url:
|
|
if url not in current_by_url:
|
|
deleted_objs.append(old_by_url[url])
|
|
|
|
## Update internal state
|
|
self.objects = list(current_by_url.values())
|
|
self._objects_by_url = None ## Invalidate cache
|
|
self.sync_token = self.calendar._generate_fake_sync_token(self.objects)
|
|
|
|
return (updated_objs, deleted_objs)
|