Code Repositories xandikos / upstream/0.1.0
Import upstream version 0.1.0, md5 d58f4a6647350757c69e99c8bfb65909 Jelmer Vernooij 6 months ago
51 changed file(s) with 2181 addition(s) and 1045 deletion(s). Raw diff Collapse all Expand all
88 htmlcov/
99 dist
1010 .pybuild
11 compat/litmus-*.tar.gz
12 compat/vdirsyncer/
13 compat/ccs-caldavtester/
1411 *.egg*
1512 child.log
1613 debug.log
00 language: python
11 cache: pip
22 sudo: true
3 dist: xenial
34 addons:
45 apt:
56 update: true
78 - 3.4
89 - 3.5
910 - 3.6
11 - 3.7
1012 - pypy3.5
1113 env:
1214 global: PYTHONHASHSEED=random
1517 - python: 3.7
1618 dist: xenial
1719 # defusedxml appears to be broken on Python 3.8:
20 # See https://github.com/tiran/defusedxml/pull/24
1821 #- python: 3.8-dev
1922 # dist: xenial
2023 install:
24 - sudo apt-get install -qq libneon27-dev curl python2.7
25 - sudo apt-get install -qq cargo make
2126 - pip install pip --upgrade
22 - pip install coverage codecov flake8 pycalendar
23 - sudo apt-get install -qq libneon27-dev curl python2.7
24 - sudo apt-get install -qq cargo
27 - pip install flake8 pycalendar vobject lxml requests six tzlocal pytz attrs
2528 - python setup.py develop
2629 script:
2730 - make style
28 - make coverage
29 - mv .coverage .coverage.unit
31 - make check
3032 # Retrieve litmus from Xandikos server for now, since webdav.org is down.
31 - make coverage-litmus LITMUS_URL=https://www.xandikos.org/litmus-0.13.tar.gz
32 - mv .coverage .coverage.litmus
33 - if [ "$TRAVIS_PYTHON_VERSION" = "3.6" ]; then
34 make coverage-vdirsyncer;
35 mv .coverage .coverage.vdirsyncer;
36 fi
37 - make coverage-caldavtester
38 - mv .coverage .coverage.caldavtester
39 after_success:
40 - python -m coverage combine
41 - codecov
33 - make check-litmus LITMUS_URL=https://www.xandikos.org/litmus-0.13.tar.gz
34 - make check-caldavtester
35 - make check-pycaldav
36 # Disabled because of https://github.com/pimutils/vdirsyncer/issues/789
37 #- if [ "$TRAVIS_PYTHON_VERSION" = "3.6" ]; then
38 # make check-vdirsyncer;
39 # fi
4240 cache:
4341 pip: true
22 Hugo Osvaldo Barrera <hugo@barrera.io>
33 Markus Unterwaditzer <markus@unterwaditzer.net>
44 Daniel M. Capella <polyzen@archlinux.info>
5 Ole-Christian S. Hagenes <ole@hagenes.net>
11 include AUTHORS
22 include COPYING
33 include Makefile
4 include TODO
54 include compat/*.sh
65 include compat/*.rst
76 include compat/*.xml
1313 $(PYTHON) -m unittest $(TESTSUITE)
1414
1515 style:
16 flake8
16 python3 -m flake8
1717
1818 web:
1919 $(PYTHON) -m xandikos.web
2323
2424 check-litmus:
2525 ./compat/xandikos-litmus.sh "${LITMUS_TESTS}"
26
27 check-pycaldav:
28 ./compat/xandikos-pycaldav.sh
29
30 coverage-pycaldav:
31 XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-pycaldav.sh
2632
2733 coverage-litmus:
2834 XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-litmus.sh "${LITMUS_TESTS}"
4551 coverage-caldavtester-all:
4652 XANDIKOS="$(XANDIKOS_COVERAGE)" ./compat/xandikos-caldavtester.sh
4753
48 check-all: check check-vdirsyncer check-litmus check-caldavtester style
54 check-all: check check-vdirsyncer check-litmus check-caldavtester check-pycaldav style
4955
5056 coverage-all: coverage coverage-litmus coverage-vdirsyncer coverage-caldavtester
5157
0 0.1.0 2019-04-07
1
2 Initial release.
+0
-191
PKG-INFO less more
0 Metadata-Version: 1.1
1 Name: xandikos
2 Version: 0.0.11
3 Summary: Lightweight CalDAV/CardDAV server
4 Home-page: https://www.xandikos.org/
5 Author: Jelmer Vernooij
6 Author-email: jelmer@jelmer.uk
7 License: GNU GPLv3 or later
8 Description: .. image:: https://travis-ci.org/jelmer/xandikos.png?branch=master
9 :target: https://travis-ci.org/jelmer/xandikos
10 :alt: Build Status
11
12 .. image:: https://ci.appveyor.com/api/projects/status/fjqtsk8agwmwavqk/branch/master?svg=true
13 :target: https://ci.appveyor.com/project/jelmer/xandikos/branch/master
14 :alt: Windows Build Status
15
16
17 Xandikos is a lightweight yet complete CardDAV/CalDAV server that backs onto a Git repository.
18
19 Xandikos (Ξανδικός or Ξανθικός) takes its name from the name of the March month
20 in the ancient Macedonian calendar, used in Macedon in the first millennium BC.
21
22 Implemented standards
23 =====================
24
25 The following standards are implemented:
26
27 - :RFC:`4918`/:RFC:`2518` (Core WebDAV) - *implemented, except for COPY/MOVE/LOCK operations*
28 - :RFC:`4791` (CalDAV) - *fully implemented*
29 - :RFC:`6352` (CardDAV) - *fully implemented*
30 - :RFC:`5397` (Current Principal) - *fully implemented*
31 - :RFC:`3253` (Versioning Extensions) - *partially implemented, only the REPORT method and {DAV:}expand-property property*
32 - :RFC:`3744` (Access Control) - *partially implemented*
33 - :RFC:`5995` (POST to create members) - *fully implemented*
34 - :RFC:`5689` (Extended MKCOL) - *fully implemented*
35
36 The following standards are not implemented:
37
38 - :RFC:`6638` (CalDAV Scheduling Extensions) - *not implemented*
39 - :RFC:`7809` (CalDAV Time Zone Extensions) - *not implemented*
40 - :RFC:`7529` (WebDAV Quota) - *not implemented*
41 - :RFC:`4709` (WebDAV Mount) - `intentionally <https://github.com/jelmer/xandikos/issues/48>`_ *not implemented*
42 - :RFC:`5546` (iCal iTIP) - *not implemented*
43 - :RFC:`4324` (iCAL CAP) - *not implemented*
44 - :RFC:`7953` (iCal AVAILABILITY) - *not implemented*
45
46 See `DAV compliance <notes/dav-compliance.rst>`_ for more detail on specification compliancy.
47
48 Limitations
49 -----------
50
51 - No multi-user support
52 - No support for CalDAV scheduling extensions
53
54 Supported clients
55 =================
56
57 Xandikos has been tested and works with the following CalDAV/CardDAV clients:
58
59 - `Vdirsyncer <https://github.com/pimutils/vdirsyncer>`_
60 - `caldavzap <https://www.inf-it.com/open-source/clients/caldavzap/>`_/`carddavmate <https://www.inf-it.com/open-source/clients/carddavmate/>`_
61 - `evolution <https://wiki.gnome.org/Apps/Evolution>`_
62 - `DAVdroid <https://davdroid.bitfire.at/>`_
63 - `sogo connector for Icedove/Thunderbird <http://v2.sogo.nu/english/downloads/frontends.html>`_
64 - `aCALdav syncer for Android <https://play.google.com/store/apps/details?id=de.we.acaldav&hl=en>`_
65 - `pycardsyncer <https://github.com/geier/pycarddav>`_
66 - `akonadi <https://community.kde.org/KDE_PIM/Akonadi>`_
67 - `CalDAV-Sync <https://dmfs.org/caldav/>`_
68 - `CardDAV-Sync <https://dmfs.org/carddav/>`_
69 - `Calendarsync <https://play.google.com/store/apps/details?id=com.icalparse>`_
70 - `Tasks <https://github.com/tasks/tasks/tree/caldav>`_
71 - `AgendaV <http://agendav.org/>`_
72 - `CardBook <https://gitlab.com/cardbook/cardbook/>`_
73
74 Dependencies
75 ============
76
77 At the moment, Xandikos supports Python 3.4 and higher as well as Pypy 3. It
78 also uses `Dulwich <https://github.com/jelmer/dulwich>`_,
79 `Jinja2 <http://jinja.pocoo.org/>`_,
80 `icalendar <https://github.com/collective/icalendar>`_, and
81 `defusedxml <https://github.com/tiran/defusedxml>`_.
82
83 E.g. to install those dependencies on Debian:
84
85 .. code:: shell
86
87 sudo apt install python3-dulwich python3-defusedxml python3-icalendar python3-jinja2
88
89 Or to install them using pip:
90
91 .. code:: shell
92
93 python setup.py develop
94
95 Docker
96 ------
97
98 A Dockerfile is also provided; see the comments on the top of the file for
99 configuration instructions.
100
101 Running
102 =======
103
104 Testing
105 -------
106
107 To run a standalone (low-performance, no authentication) instance of Xandikos,
108 with a pre-created calendar and addressbook (storing data in *$HOME/dav*):
109
110 .. code:: shell
111
112 ./bin/xandikos --defaults -d $HOME/dav
113
114 A server should now be listening on `localhost:8080 <http://localhost:8080/>`_.
115
116 Note that Xandikos does not create any collections unless --defaults is
117 specified. You can also either create collections from your CalDAV/CardDAV client,
118 or by creating git repositories under the *contacts* or *calendars* directories
119 it has created.
120
121 Production
122 ----------
123
124 The easiest way to run Xandikos in production is using
125 `uWSGI <https://uwsgi-docs.readthedocs.io/en/latest/>`_.
126
127 One option is to setup uWSGI with a server like
128 `Apache <http://uwsgi-docs.readthedocs.io/en/latest/Apache.html>`_,
129 `Nginx <http://uwsgi-docs.readthedocs.io/en/latest/Nginx.html>`_ or another web
130 server that can authenticate users and forward authorized requests to
131 Xandikos in uWSGI. See `examples/uwsgi.ini <examples/uwsgi.ini>`_ for an
132 example uWSGI configuration.
133
134 Alternatively, you can run uWSGI standalone and have it authenticate and
135 directly serve HTTP traffic. An example configuration for this can be found in
136 `examples/uwsgi-standalone.ini <examples/uwsgi-standalone.ini>`_.
137
138 This will start a server on `localhost:8080 <http://localhost:8080/>`_ with username *user1* and password
139 *password1*.
140
141 .. code:: shell
142
143 mkdir -p $HOME/dav
144 uwsgi examples/uwsgi-standalone.ini
145
146 Client instructions
147 ===================
148
149 Some clients can automatically discover the calendars and addressbook URLs from
150 a DAV server (if they support RFC:`5397`). For such clients you can simply
151 provide the base URL to Xandikos during setup.
152
153 Clients that lack such automated discovery (e.g. Thunderbird Lightning) require
154 the direct URL to a calendar or addressbook. In this case you
155 should provide the full URL to the calendar or addressbook; if you initialized
156 Xandikos using the ``--defaults`` argument mentioned in the previous section,
157 these URLs will look something like this::
158
159 http://dav.example.com/user/calendars/calendar
160
161 http://dav.example.com/user/contacts/addressbook
162
163
164 Contributing
165 ============
166
167 Contributions to Xandikos are very welcome. If you run into bugs or have
168 feature requests, please file issues `on GitHub
169 <https://github.com/jelmer/xandikos/issues/new>`_. If you're interested in
170 contributing code or documentation, please read `CONTRIBUTING
171 <CONTRIBUTING.rst>`_. Issues that are good for new contributors are tagged
172 `new-contributor <https://github.com/jelmer/xandikos/labels/new-contributor>`_
173 on GitHub.
174
175 Help
176 ====
177
178 There is a *#xandikos* IRC channel on the `Freenode <https://www.freenode.net/>`_
179 IRC network, and a `Xandikos <https://groups.google.com/forum/#!forum/xandikos>`_
180 mailing list.
181
182 Platform: UNKNOWN
183 Classifier: Development Status :: 4 - Beta
184 Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
185 Classifier: Programming Language :: Python :: 3.4
186 Classifier: Programming Language :: Python :: 3.5
187 Classifier: Programming Language :: Python :: 3.6
188 Classifier: Programming Language :: Python :: Implementation :: CPython
189 Classifier: Programming Language :: Python :: Implementation :: PyPy
190 Classifier: Operating System :: POSIX
5151 - `Vdirsyncer <https://github.com/pimutils/vdirsyncer>`_
5252 - `caldavzap <https://www.inf-it.com/open-source/clients/caldavzap/>`_/`carddavmate <https://www.inf-it.com/open-source/clients/carddavmate/>`_
5353 - `evolution <https://wiki.gnome.org/Apps/Evolution>`_
54 - `DAVdroid <https://davdroid.bitfire.at/>`_
54 - `DAVx5 <https://www.davx5.com/>`_ (formerly DAVDroid)
5555 - `sogo connector for Icedove/Thunderbird <http://v2.sogo.nu/english/downloads/frontends.html>`_
5656 - `aCALdav syncer for Android <https://play.google.com/store/apps/details?id=de.we.acaldav&hl=en>`_
5757 - `pycardsyncer <https://github.com/geier/pycarddav>`_
6262 - `Tasks <https://github.com/tasks/tasks/tree/caldav>`_
6363 - `AgendaV <http://agendav.org/>`_
6464 - `CardBook <https://gitlab.com/cardbook/cardbook/>`_
65 - Apple's iOS
6566
6667 Dependencies
6768 ============
6869
6970 At the moment, Xandikos supports Python 3.4 and higher as well as Pypy 3. It
70 also uses `Dulwich <https://github.com/jelmer/dulwich>`_,
71 also uses `Dulwich <https://github.com/dulwich/dulwich>`_,
7172 `Jinja2 <http://jinja.pocoo.org/>`_,
7273 `icalendar <https://github.com/collective/icalendar>`_, and
7374 `defusedxml <https://github.com/tiran/defusedxml>`_.
+0
-20
TODO less more
0 webdav server:
1 - add support for authorization
2 - implement COPY
3 - implement MOVE
4 - implement LOCK
5
6 - run caldav tester
7
8 - cross-check UIDs for vcard files
9 - support returning components in addressbook-data
10
11 - properties:
12 - calendar-proxy-read-for
13 - calendar-proxy-write-for
14
15 - better author data in commits
16
17 - improve calendar delta describer
18
19 - improve performance
0 litmus-*.tar.gz
1 vdirsyncer/
2 ccs-caldavtester/
3 pycaldav/
1010 set -e
1111
1212 xandikos_cleanup() {
13 [ -z ${XANDIKOS_PID} ] || kill -TERM ${XANDIKOS_PID}
13 [ -z ${XANDIKOS_PID} ] || kill -INT ${XANDIKOS_PID}
1414 rm --preserve-root -rf ${SERVEDIR}
1515 cat ${DAEMON_LOG}
1616 wait ${XANDIKOS_PID} || true
1212 fi
1313
1414 cd ccs-caldavtester
15 exec env python2 ./testcaldav.py "$@"
15 python2 ./testcaldav.py "$@"
0 #!/bin/bash
1 # Run python-caldav tests against Xandikos.
2 set -e
3
4 . $(dirname $0)/common.sh
5
6 BRANCH=master
7
8 if [ ! -d $(dirname $0)/pycaldav ]; then
9 git clone https://github.com/python-caldav/caldav $(dirname $0)/pycaldav
10 else
11 pushd $(dirname $0)/pycaldav
12 git pull --ff-only origin $BRANCH
13 popd
14 fi
15
16 cat <<EOF>$(dirname $0)/pycaldav/tests/conf_private.py
17 # Only run tests against my private caldav servers.
18 only_private = True
19
20 caldav_servers = [
21 {'url': 'http://localhost:5233/',
22 # Until recurring support is added in xandikos.
23 # See https://github.com/jelmer/xandikos/issues/102
24 'norecurring': True,
25 }
26 ]
27 EOF
28
29 run_xandikos 5233 --defaults
30
31 pushd $(dirname $0)/pycaldav
32 ${PYTHON:-python3} -m pytest tests "$@"
33 popd
249249 ^^^^^^^^^^^^^^^^^^^^^^^^^
250250
251251 - calendar-color [supported]
252 - calendar-order [supported]
252253 - getctag [supported]
253254 - refreshrate [supported]
254255
0 Debugging Xandikos
1 ==================
2
3 When filing bugs, please include details on the Xandikos version you're running
4 and the clients that you're using.
5
6 It would be helpful if you can reproduce any issues with a clean Xandikos
7 setup. That also makes it easier to e.g. share log files.
8
9 1. Verify the server side contents; you can do this by
10 looking at the Git repository on the Xandikos side.
11 2. Run with ``xandikos --dump-dav-xml``; please note that these
12 may contain personal information, so be careful before e.g. posting
13 them on GitHub.
0 Filter Performance
1 ==================
2
3 There are several API calls that would be good to speed up. In particular,
4 querying an entire calendar with filters is quite slow because it involves
5 scanning all the items.
6
7 Common Filters
8 ~~~~~~~~~~~~~~
9
10 There are a couple of common filters:
11
12 Component filters that filter for only VTODO or VEVENT items
13 Property filters that filter for a specific UID
14 Property filters that filter for another property
15 Property filters that do complex text searches, e.g. in DESCRIPTION
16 Property filters that filter for some time range.
17
18 But these are by no means the only possible filters, and there is no
19 predicting what clients will scan for.
20
21 Indexes are an implementation detail of the Store. This is necessary so that
22 e.g. the Git stores can take advantage of the fact that they have a tree hash.
23
24 One option would be to serialize the filter and then to keep a list of results
25 per (tree_id, filter_hash). Unfortunately this by itself is not enough, since
26 it doesn't help when we get repeated queries for different UIDs.
27
28 Options considered:
29
30 * Have some pre-set indexes. Perhaps components, and UID?
31 * Cache but use the rightmost value as a key in a dict
32 * Always just cache everything that was queried. This is probably actually fine.
33 * Count how often a particular index is used
34
35 Open Questions
36 ~~~~~~~~~~~~~~
37
38 * How are indexes identified?
39
40 Proposed API
41 ~~~~~~~~~~~~
42
43 class Filter(object):
44
45 def check_slow(self, name, resource):
46 """Check whether this filter applies to a resources based on the actual
47 resource.
48
49 This is the naive, slow, fallback implementation.
50
51 :param resource: Resource to check
52 """
53 raise NotImplementedError(self.check_slow)
54
55 def check_index(self, values):
56 """Check whether this filter applies to a resources based on index values.
57
58 :param values: Dictionary mapping indexes to index values
59 """
60 raise NotImplementedError(self.check_index)
61
62 def required_indexes(self):
63 """Return a list of indexes that this Filter needs to function.
64
65 :return: List of ORed options, similar to a Depends line in Debian
66 """
67 raise NotImplementedError(self.required_indexes)
68
2525 In the simplest form, users only have access to the resources under their own
2626 principal.
2727
28 As a second step, we could let users configure ACLs; one way of doing this would be
29 to allow adding authentication in the collection configuration. I.e. something like::
30
31 [acl]
32 read = jelmer, joe
33 write = jelmer
34
2835 Storage
2936 -------
3037
0 Prometheus
1 ==========
2
3 Proposed metrics:
4
5 * number of HTTP queries
6 * number of DAV queries by category
7 * DAV versions used
0 Subcommands
1 ===========
2
3 At the moment, the Xandikos command just supports running a
4 (debug) webserver. In various situations it would also be useful
5 to have subcommands for adminstrative operations.
6
7 Propose subcommands:
8
9 * ``xandikos init [--defaults] [--autocreate] [-d DIRECTORY]`` -
10 create a Xandikos database
11 * ``xandikos stats`` - dump stats, similar to those exposed by prometheus
12 * ``xandikos web`` - run a debug web server
13
00 [flake8]
11 ignore = W504
2 exclude = compat/vdirsyncer/,.tox,compat/ccs-caldavtester,.git
2 exclude = compat/vdirsyncer/,.tox,compat/ccs-caldavtester,.git,compat/pycaldav
33 application-package-names = xandikos
4
5 [egg_info]
6 tag_build =
7 tag_date = 0
8
11 # encoding: utf-8
22 #
33 # Xandikos
4 # Copyright (C) 2016-2017 Jelmer Vernooij <jelmer@jelmer.uk>, et al.
4 # Copyright (C) 2016-2017 Jelmer Vernooij <jelmer@jelmer.uk>, et al.
55 #
66 # This program is free software; you can redistribute it and/or
77 # modify it under the terms of the GNU General Public License
2222 from setuptools import find_packages, setup
2323 import sys
2424
25 version = "0.0.11"
26
27 if sys.platform != 'win32':
28 # Win32 setup breaks on non-ascii characters
29 author = "Jelmer Vernooij"
30 else:
31 author = "Jelmer Vernooij"
25 version = "0.1.0"
3226
3327 with open('README.rst', encoding='utf-8') as f:
3428 long_description = f.read()
29
30 if sys.platform == 'win32':
31 # Strip out non-mbcs characters
32 long_description = long_description.encode('ascii', 'replace').decode()
3533
3634 setup(name="xandikos",
3735 description="Lightweight CalDAV/CardDAV server",
3836 long_description=long_description,
3937 version=version,
40 author=author,
38 author="Jelmer Vernooij",
4139 author_email="jelmer@jelmer.uk",
4240 license="GNU GPLv3 or later",
4341 url="https://www.xandikos.org/",
5755 'Programming Language :: Python :: 3.4',
5856 'Programming Language :: Python :: 3.5',
5957 'Programming Language :: Python :: 3.6',
58 'Programming Language :: Python :: 3.7',
6059 'Programming Language :: Python :: Implementation :: CPython',
6160 'Programming Language :: Python :: Implementation :: PyPy',
6261 'Operating System :: POSIX',
2020
2121 """CalDAV/CardDAV server."""
2222
23 __version__ = (0, 0, 11)
23 __version__ = (0, 1, 0)
2424 version_string = '.'.join(map(str, __version__))
2525
2626 import defusedxml.ElementTree # noqa: This does some monkey-patching on-load
2121 https://tools.ietf.org/html/rfc4791
2222 """
2323 import datetime
24 import logging
24 import itertools
2525 import pytz
2626
27 from .icalendar import (
28 apply_time_range_vevent,
29 as_tz_aware_ts,
30 )
2731 from icalendar.cal import component_factory, Calendar as ICalendar, FreeBusy
2832 from icalendar.prop import vDDDTypes, vPeriod, LocalTimezone
2933
5155 TRANSPARENCY_OPAQUE = 'opaque'
5256
5357
54 class MissingProperty(Exception):
55
56 def __init__(self, property_name):
57 super(MissingProperty, self).__init__(
58 "Property %r missing" % property_name)
59 self.property_name = property_name
60
61
6258 class Calendar(webdav.Collection):
6359
6460 resource_types = (webdav.Collection.resource_types +
7672 """Set the calendar color."""
7773 raise NotImplementedError(self.set_calendar_color)
7874
75 def get_calendar_order(self):
76 """Return the calendar order."""
77 raise NotImplementedError(self.get_calendar_order)
78
79 def set_calendar_order(self, order):
80 """Set the calendar order."""
81 raise NotImplementedError(self.set_calendar_order)
82
7983 def get_calendar_timezone(self):
8084 """Return calendar timezone.
8185
8488 """
8589 raise NotImplementedError(self.get_calendar_timezone)
8690
87 def set_calendar_timezone(self):
91 def set_calendar_timezone(self, content):
8892 """Set calendar timezone.
8993
9094 This should be an iCalendar object with exactly one
138142 """Return max attachment size."""
139143 raise NotImplementedError(self.get_max_attachment_size)
140144
145 def get_schedule_calendar_transparency(self):
146 """Get calendar transparency.
147
148 Possible values are TRANSPARENCY_TRANSPARENT and TRANSPARENCY_OPAQUE
149 """
150 return TRANSPARENCY_OPAQUE
151
152 def calendar_query(self, create_filter_fn):
153 """Query for all the members of this calendar that match `filter`.
154
155 This is a naive implementation; subclasses should ideally provide
156 their own implementation that is faster.
157
158 :param create_filter_fn: Callback that constructs a
159 filter; takes a filter building class.
160 :return: Iterator over name, resource objects
161 """
162 raise NotImplementedError(self.calendar_query)
163
164
165 class CalendarHomeSet(object):
166
141167 def get_managed_attachments_server_url(self):
142168 """Return the attachments server URL."""
143169 raise NotImplementedError(self.get_managed_attachments_server_url)
144
145 def get_schedule_calendar_transparency(self):
146 """Get calendar transparency.
147
148 Possible values are TRANSPARENCY_TRANSPARENT and TRANSPARENCY_OPAQUE
149 """
150 return TRANSPARENCY_OPAQUE
151170
152171
153172 class PrincipalExtensions:
220239 extract_from_calendar(insub, outsub, tag)
221240 elif tag.tag == ('{%s}prop' % NAMESPACE):
222241 outcal[tag.get('name')] = incal[tag.get('name')]
242 elif tag.tag == ('{%s}expand' % NAMESPACE):
243 # TODO(jelmer): https://github.com/jelmer/xandikos/issues/102
244 raise NotImplementedError('expand is not yet implemented')
245 elif tag.tag == ('{%s}limit-recurrence-set' % NAMESPACE):
246 # TODO(jelmer): https://github.com/jelmer/xandikos/issues/103
247 raise NotImplementedError(
248 'limit-recurrence-set is not yet implemented')
249 elif tag.tag == ('{%s}limit-freebusy-set' % NAMESPACE):
250 # TODO(jelmer): https://github.com/jelmer/xandikos/issues/104
251 raise NotImplementedError(
252 'limit-freebusy-set is not yet implemented')
223253 else:
224254 raise AssertionError('invalid element %r' % tag)
225255
253283 el.text = serialized_cal.decode('utf-8')
254284
255285
286 class CalendarOrderProperty(webdav.Property):
287 """Provides calendar-order property.
288 """
289
290 name = '{http://apple.com/ns/ical/}calendar-order'
291 resource_type = CALENDAR_RESOURCE_TYPE
292
293 def get_value(self, base_href, resource, el, environ):
294 el.text = resource.get_calendar_order()
295
296 def set_value(self, href, resource, el):
297 resource.set_calendar_order(el.text)
298
299
256300 class CalendarMultiGetReporter(davcommon.MultiGetReporter):
257301
258302 name = '{%s}calendar-multiget' % NAMESPACE
260304 data_property = CalendarDataProperty()
261305
262306
263 def apply_prop_filter(el, comp, tzify):
307 def parse_prop_filter(el, cls):
264308 name = el.get('name')
309
265310 # From https://tools.ietf.org/html/rfc4791, 9.7.2:
266311 # A CALDAV:comp-filter is said to match if:
267312
268 # The CALDAV:prop-filter XML element contains a CALDAV:is-not-defined XML
269 # element and no property of the type specified by the "name" attribute
270 # exists in the enclosing calendar component;
271 if (
272 len(el) == 1 and
273 el[0].tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined'
274 ):
275 return name not in comp
276
277 try:
278 prop = comp[name]
279 except KeyError:
280 return False
313 prop_filter = cls(name=name)
281314
282315 for subel in el:
283 if subel.tag == '{urn:ietf:params:xml:ns:caldav}time-range':
284 if not apply_time_range_prop(subel, prop, tzify):
285 return False
316 if subel.tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined':
317 prop_filter.is_not_defined = True
318 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}time-range':
319 parse_time_range(subel, prop_filter.filter_time_range)
286320 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}text-match':
287 if not apply_text_match(subel, prop):
288 return False
321 parse_text_match(subel, prop_filter.filter_text_match)
289322 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}param-filter':
290 if not apply_param_filter(subel, prop):
291 return False
292 return True
293
294
295 def apply_text_match(el, value):
323 parse_param_filter(subel, prop_filter.filter_parameter)
324 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined':
325 pass
326 else:
327 raise AssertionError("unknown subelement %r" % subel.tag)
328 return prop_filter
329
330
331 def parse_text_match(el, cls):
296332 collation = el.get('collation', 'i;ascii-casemap')
297333 negate_condition = el.get('negate-condition', 'no')
298 matches = davcommon.get_collation(collation)(el.text, value)
299
300 if negate_condition == 'yes':
301 return (not matches)
302 else:
303 return matches
304
305
306 def apply_param_filter(el, prop):
334
335 return cls(
336 el.text, collation=collation,
337 negate_condition=(negate_condition == 'yes'))
338
339
340 def parse_param_filter(el, cls):
307341 name = el.get('name')
308 if (
309 len(el) == 1 and
310 el[0].tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined'
311 ):
312 return name not in prop.params
313
314 try:
315 value = prop.params[name]
316 except KeyError:
317 return False
342
343 param_filter = cls(name=name)
318344
319345 for subel in el:
320 if subel.tag == '{urn:ietf:params:xml:ns:caldav}text-match':
321 if not apply_text_match(subel, value):
322 return False
346 if subel.tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined':
347 param_filter.is_not_defined = True
348 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}text-match':
349 parse_text_match(subel, param_filter.filter_time_range)
323350 else:
324351 raise AssertionError('unknown tag %r in param-filter', subel.tag)
325 return True
352 return param_filter
326353
327354
328355 def _parse_time_range(el):
343370 return (start, end)
344371
345372
346 def as_tz_aware_ts(dt, default_timezone):
347 if not getattr(dt, 'time', None):
348 dt = datetime.datetime.combine(dt, datetime.time())
349 if dt.tzinfo is None:
350 dt = dt.replace(tzinfo=default_timezone)
351 assert dt.tzinfo
352 return dt
353
354
355 def apply_time_range_vevent(start, end, comp, tzify):
356 if comp['DTSTART'] is None:
357 raise MissingProperty('DTSTART')
358
359 if not (end > tzify(comp['DTSTART'].dt)):
360 return False
361
362 if 'DTEND' in comp:
363 if tzify(comp['DTEND'].dt) < tzify(comp['DTSTART'].dt):
364 logging.debug('Invalid DTEND < DTSTART')
365 return (start < tzify(comp['DTEND'].dt))
366
367 if 'DURATION' in comp:
368 return (start < tzify(comp['DTSTART'].dt) + comp['DURATION'].dt)
369 if getattr(comp['DTSTART'].dt, 'time', None) is not None:
370 return (start <= tzify(comp['DTSTART'].dt))
371 else:
372 return (start < (tzify(comp['DTSTART'].dt) + datetime.timedelta(1)))
373
374
375 def apply_time_range_vjournal(start, end, comp, tzify):
376 if 'DTSTART' not in comp:
377 return False
378
379 if not (end > tzify(comp['DTSTART'].dt)):
380 return False
381
382 if getattr(comp['DTSTART'].dt, 'time', None) is not None:
383 return (start <= tzify(comp['DTSTART'].dt))
384 else:
385 return (start < (tzify(comp['DTSTART'].dt) + datetime.timedelta(1)))
386
387
388 def apply_time_range_vtodo(start, end, comp, tzify):
389 if 'DTSTART' in comp:
390 if 'DURATION' in comp and 'DUE' not in comp:
391 return (
392 start <= tzify(comp['DTSTART'].dt) + comp['DURATION'].dt and
393 (end > tzify(comp['DTSTART'].dt) or
394 end >= tzify(comp['DTSTART'].dt) + comp['DURATION'].dt)
395 )
396 elif 'DUE' in comp and 'DURATION' not in comp:
397 return (
398 (start <= tzify(comp['DTSTART'].dt) or
399 start < tzify(comp['DUE'].dt)) and
400 (end > tzify(comp['DTSTART'].dt) or
401 end < tzify(comp['DUE'].dt))
402 )
403 else:
404 return (start <= tzify(comp['DTSTART'].dt) and
405 end > tzify(comp['DTSTART'].dt))
406 elif 'DUE' in comp:
407 return start < tzify(comp['DUE'].dt) and end >= tzify(comp['DUE'].dt)
408 elif 'COMPLETED' in comp:
409 if 'CREATED' in comp:
410 return (
411 (start <= tzify(comp['CREATED'].dt) or
412 start <= tzify(comp['COMPLETED'].dt)) and
413 (end >= tzify(comp['CREATED'].dt) or
414 end >= tzify(comp['COMPLETED'].dt))
415 )
416 else:
417 return (
418 start <= tzify(comp['COMPLETED'].dt) and
419 end >= tzify(comp['COMPLETED'].dt)
420 )
421 elif 'CREATED' in comp:
422 return end >= tzify(comp['CREATED'].dt)
423 else:
424 return True
425
426
427 def apply_time_range_vfreebusy(start, end, comp, tzify):
428 if 'DTSTART' in comp and 'DTEND' in comp:
429 return (
430 start <= tzify(comp['DTEND'].dt) and
431 end > tzify(comp['DTEND'].dt)
432 )
433
434 for period in comp.get('FREEBUSY', []):
435 if start < period.end and end > period.start:
436 return True
437
438 return False
439
440
441 def apply_time_range_valarm(start, end, comp, tzify):
442 raise NotImplementedError(apply_time_range_valarm)
443
444
445 def apply_time_range_comp(el, comp, tzify):
446 # According to https://tools.ietf.org/html/rfc4791, section 9.9 these are
447 # the properties to check.
373 def parse_time_range(el, cls):
448374 (start, end) = _parse_time_range(el)
449 component_handlers = {
450 'VEVENT': apply_time_range_vevent,
451 'VTODO': apply_time_range_vtodo,
452 'VJOURNAL': apply_time_range_vjournal,
453 'VFREEBUSY': apply_time_range_vfreebusy,
454 'VALARM': apply_time_range_valarm}
455 try:
456 component_handler = component_handlers[comp.name]
457 except KeyError:
458 logging.warning('unknown component %r in time-range filter',
459 comp.name)
460 return False
461 return component_handler(start, end, comp, tzify)
462
463
464 def apply_time_range_prop(el, val, tzify):
465 (start, end) = _parse_time_range(el)
466 raise NotImplementedError(apply_time_range_prop)
467
468
469 def apply_comp_filter(el, comp, tzify):
375 return cls(start, end)
376
377
378 def parse_comp_filter(el, cls):
470379 """Compile a comp-filter element into a Python function.
471380 """
472381 name = el.get('name')
382
473383 # From https://tools.ietf.org/html/rfc4791, 9.7.1:
474384 # A CALDAV:comp-filter is said to match if:
475385
476 # 2. The CALDAV:comp-filter XML element contains a CALDAV:is-not-defined
477 # XML element and the calendar object or calendar component type specified
478 # by the "name" attribute does not exist in the current scope;
479 if (
480 len(el) == 1 and
481 el[0].tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined'
482 ):
483 return comp.name != name
484
485 # 1: The CALDAV:comp-filter XML element is empty and the calendar object or
486 # calendar component type specified by the "name" attribute exists in the
487 # current scope;
488 if comp.name != name:
489 return False
386 comp_filter = cls(name=name)
490387
491388 # 3. The CALDAV:comp-filter XML element contains a CALDAV:time-range XML
492389 # element and at least one recurrence instance in the targeted calendar
494391 # specified CALDAV:prop-filter and CALDAV:comp-filter child XML elements
495392 # also match the targeted calendar component;
496393 for subel in el:
394 if subel.tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined':
395 comp_filter.is_not_defined = True
497396 if subel.tag == '{urn:ietf:params:xml:ns:caldav}comp-filter':
498 if not any(apply_comp_filter(subel, c, tzify)
499 for c in comp.subcomponents):
500 return False
397 parse_comp_filter(subel, comp_filter.filter_subcomponent)
501398 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}prop-filter':
502 if not apply_prop_filter(subel, comp, tzify):
503 return False
399 parse_prop_filter(subel, comp_filter.filter_property)
504400 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}time-range':
505 if not apply_time_range_comp(subel, comp, tzify):
506 return False
401 parse_time_range(subel, comp_filter.filter_time_range)
507402 else:
508403 raise AssertionError('unknown filter tag %r' % subel.tag)
509 return True
404 return comp_filter
405
406
407 def parse_filter(filter_el, cls):
408 for subel in filter_el:
409 if subel.tag == '{urn:ietf:params:xml:ns:caldav}comp-filter':
410 parse_comp_filter(subel, cls.filter_subcomponent)
411 else:
412 raise AssertionError('unknown filter tag %r' % subel.tag)
413 return cls
510414
511415
512416 def calendar_from_resource(resource):
518422 return resource.file.calendar
519423
520424
521 def apply_filter(el, resource, tzify):
522 """Compile a filter element into a Python function.
523 """
524 if el is None:
525 # Empty filter, let's not bother parsing
526 return lambda x: True
527 c = calendar_from_resource(resource)
528 if c is None:
529 return False
530 return apply_comp_filter(list(el)[0], c, tzify)
531
532
533425 def extract_tzid(cal):
534426 return cal.subcomponents[0]['TZID']
535427
557449 @webdav.multistatus
558450 def report(self, environ, body, resources_by_hrefs, properties, base_href,
559451 base_resource, depth):
560 # TODO(jelmer): Verify that resource is an addressbook
452 # TODO(jelmer): Verify that resource is a calendar
561453 requested = None
562454 filter_el = None
563455 tztext = None
576468 else:
577469 tz = get_calendar_timezone(base_resource)
578470
579 def tzify(dt):
580 return as_tz_aware_ts(dt, tz)
471 def filter_fn(cls):
472 return parse_filter(filter_el, cls(tz))
473
474 def members(collection):
475 return itertools.chain(
476 collection.calendar_query(filter_fn),
477 collection.subcollections())
478
581479 for (href, resource) in webdav.traverse_resource(
582 base_resource, base_href, depth):
583 try:
584 filter_result = apply_filter(filter_el, resource, tzify)
585 except MissingProperty as e:
586 logging.warning(
587 'calendar_query: Ignoring calendar object %s, due '
588 'to missing property %s', href, e.property_name)
589 continue
590 if not filter_result:
591 continue
592 propstat = davcommon.get_properties_with_data(
593 self.data_property, href, resource, properties, environ,
594 requested)
595 yield webdav.Status(href, '200 OK', propstat=list(propstat))
480 base_resource, base_href, depth,
481 members=members):
482 # Ideally traverse_resource would only return the right things.
483 if getattr(resource, 'content_type', None) == 'text/calendar':
484 propstat = davcommon.get_properties_with_data(
485 self.data_property, href, resource, properties, environ,
486 requested)
487 yield webdav.Status(href, '200 OK', propstat=list(propstat))
596488
597489
598490 class CalendarColorProperty(webdav.Property):
798690 in_allprops = False
799691
800692 def get_value(self, base_href, resource, el, environ):
801 href = resource.get_managed_attachments_server_url()
693 # The RFC specifies that this property can be set on a calendar home
694 # collection.
695 # However, there is no matching resource type and we don't want to
696 # force all resources to implement it. So we just check whether the
697 # attribute is present.
698 fn = getattr(resource, 'get_managed_attachments_server_url', None)
699 if fn is None:
700 raise KeyError
701 href = fn()
802702 if href is not None:
803703 el.append(webdav.create_href(href, base_href))
804704
2020
2121 https://tools.ietf.org/html/rfc6352
2222 """
23 from xandikos import davcommon, webdav
23 from xandikos import (
24 collation as _mod_collation,
25 davcommon,
26 webdav,
27 )
2428
2529 ET = webdav.ET
2630
228232 match_type = el.get('match-type', 'contains')
229233 if match_type != 'contains':
230234 raise NotImplementedError('match_type != contains: %r' % match_type)
231 matches = davcommon.collations[collation](el.text, value)
235 matches = _mod_collation.collations[collation](el.text, value)
232236
233237 if negate_condition == 'yes':
234238 return (not matches)
0 # Xandikos
1 # Copyright (C) 2016-2017 Jelmer Vernooij <jelmer@jelmer.uk>, et al.
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 3
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """Collations."""
20
21
22 class UnknownCollation(Exception):
23
24 def __init__(self, collation):
25 super(UnknownCollation, self).__init__(
26 "Collation %r is not supported" % collation)
27 self.collation = collation
28
29
30 collations = {
31 'i;ascii-casemap': lambda a, b: (a.decode('ascii').upper() ==
32 b.decode('ascii').upper()),
33 'i;octet': lambda a, b: a == b,
34 }
35
36
37 def get_collation(name):
38 """Get a collation by name.
39
40 :param name: Collation name
41 :raises UnknownCollation: If the collation is not supported
42 """
43 try:
44 return collations[name]
45 except KeyError:
46 raise UnknownCollation(name)
8080
8181
8282 # see https://tools.ietf.org/html/rfc4790
83
84 class UnknownCollation(Exception):
85
86 def __init__(self, collation):
87 super(UnknownCollation, self).__init__(
88 "Collation %r is not supported" % collation)
89 self.collation = collation
90
91
92 collations = {
93 'i;ascii-casemap': lambda a, b: (a.decode('ascii').upper() ==
94 b.decode('ascii').upper()),
95 'i;octet': lambda a, b: a == b,
96 }
97
98
99 def get_collation(name):
100 """Get a collation by name.
101
102 :param name: Collation name
103 :raises UnknownCollation: If the collation is not supported
104 """
105 try:
106 return collations[name]
107 except KeyError:
108 raise UnknownCollation(name)
2020
2121 """
2222
23 import datetime
2324 import logging
2425
2526 from icalendar.cal import Calendar, component_factory
26 from icalendar.prop import vText
27 from xandikos.store import File, InvalidFileContents
27 from icalendar.prop import (
28 vDatetime,
29 vDDDTypes,
30 vText,
31 )
32 from xandikos.store import (
33 Filter,
34 File,
35 InvalidFileContents,
36 )
37
38 from . import (
39 collation as _mod_collation,
40 )
2841
2942 # TODO(jelmer): Populate this further based on
3043 # https://tools.ietf.org/html/rfc5545#3.3.11
3144 _INVALID_CONTROL_CHARACTERS = ['\x0c', '\x01']
45
46
47 class MissingProperty(Exception):
48
49 def __init__(self, property_name):
50 super(MissingProperty, self).__init__(
51 "Property %r missing" % property_name)
52 self.property_name = property_name
3253
3354
3455 def validate_calendar(cal, strict=False):
3960 """
4061 for error in validate_component(cal, strict=strict):
4162 yield error
63
64
65 def create_subindexes(indexes, base):
66 ret = {}
67 for k, v in indexes.items():
68 if k is not None and k.startswith(base + '/'):
69 ret[k[len(base) + 1:]] = v
70 elif k == base:
71 ret[None] = v
72 return ret
4273
4374
4475 def validate_component(comp, strict=False):
199230 field, old_value, new_value)
200231
201232
233 def apply_time_range_vevent(start, end, comp, tzify):
234 dtstart = comp.get('DTSTART')
235 if not dtstart:
236 raise MissingProperty('DTSTART')
237
238 if not (end > tzify(dtstart.dt)):
239 return False
240
241 dtend = comp.get('DTEND')
242 if dtend:
243 if tzify(dtend.dt) < tzify(dtstart.dt):
244 logging.debug('Invalid DTEND < DTSTART')
245 return (start < tzify(dtend.dt))
246
247 duration = comp.get('DURATION')
248 if duration:
249 return (start < tzify(dtstart.dt) + duration.dt)
250 if getattr(dtstart.dt, 'time', None) is not None:
251 return (start <= tzify(dtstart.dt))
252 else:
253 return (start < (tzify(dtstart.dt) + datetime.timedelta(1)))
254
255
256 def apply_time_range_vjournal(start, end, comp, tzify):
257 dtstart = comp.get('DTSTART')
258 if not dtstart:
259 raise MissingProperty('DTSTART')
260
261 if not (end > tzify(dtstart.dt)):
262 return False
263
264 if getattr(dtstart.dt, 'time', None) is not None:
265 return (start <= tzify(dtstart.dt))
266 else:
267 return (start < (tzify(dtstart.dt) + datetime.timedelta(1)))
268
269
270 def apply_time_range_vtodo(start, end, comp, tzify):
271 dtstart = comp.get('DTSTART')
272 due = comp.get('DUE')
273
274 # See RFC4719, section 9.9
275 if dtstart:
276 duration = comp.get('DURATION')
277 if duration and not due:
278 return (
279 start <= tzify(dtstart.dt) + duration.dt and
280 (end > tzify(dtstart.dt) or
281 end >= tzify(dtstart.dt) + duration.dt)
282 )
283 elif due and not duration:
284 return (
285 (start <= tzify(dtstart.dt) or
286 start < tzify(due.dt)) and
287 (end > tzify(dtstart.dt) or
288 end < tzify(due.dt))
289 )
290 else:
291 return (start <= tzify(dtstart.dt) and
292 end > tzify(dtstart.dt))
293
294 if due:
295 return start < tzify(due.dt) and end >= tzify(due.dt)
296
297 completed = comp.get('COMPLETED')
298 created = comp.get('CREATED')
299 if completed:
300 if created:
301 return (
302 (start <= tzify(created.dt) or
303 start <= tzify(completed.dt)) and
304 (end >= tzify(created.dt) or
305 end >= tzify(completed.dt))
306 )
307 else:
308 return (
309 start <= tzify(completed.dt) and
310 end >= tzify(completed.dt)
311 )
312 elif created:
313 return end >= tzify(created.dt)
314 else:
315 return True
316
317
318 def apply_time_range_vfreebusy(start, end, comp, tzify):
319 dtstart = comp.get('DTSTART')
320 dtend = comp.get('DTEND')
321 if dtstart and dtend:
322 return (
323 start <= tzify(dtend.dt) and
324 end > tzify(dtstart.dt)
325 )
326
327 for period in comp.get('FREEBUSY', []):
328 if start < period.end and end > period.start:
329 return True
330
331 return False
332
333
334 def apply_time_range_valarm(start, end, comp, tzify):
335 raise NotImplementedError(apply_time_range_valarm)
336
337
338 class PropertyTimeRangeMatcher(object):
339
340 def __init__(self, start, end):
341 self.start = start
342 self.end = end
343
344 def __repr__(self):
345 return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end)
346
347 def match(self, prop, tzify):
348 dt = tzify(prop.dt)
349 return (dt >= self.start and dt <= self.end)
350
351 def match_indexes(self, prop, tzify):
352 return any(self.match(vDDDTypes(vDatetime.from_ical(p)), tzify)
353 for p in prop[None])
354
355
356 class ComponentTimeRangeMatcher(object):
357
358 all_props = [
359 'DTSTART', 'DTEND', 'DURATION', 'CREATED', 'COMPLETED', 'DUE',
360 'FREEBUSY']
361
362 # According to https://tools.ietf.org/html/rfc4791, section 9.9 these
363 # are the properties to check.
364 component_handlers = {
365 'VEVENT': apply_time_range_vevent,
366 'VTODO': apply_time_range_vtodo,
367 'VJOURNAL': apply_time_range_vjournal,
368 'VFREEBUSY': apply_time_range_vfreebusy,
369 'VALARM': apply_time_range_valarm}
370
371 def __init__(self, start, end, comp=None):
372 self.start = start
373 self.end = end
374 self.comp = comp
375
376 def __repr__(self):
377 if self.comp is not None:
378 return "%s(%r, %r, comp=%r)" % (
379 self.__class__.__name__, self.start, self.end, self.comp)
380 else:
381 return "%s(%r, %r)" % (
382 self.__class__.__name__, self.start, self.end)
383
384 def match(self, comp, tzify):
385 try:
386 component_handler = self.component_handlers[comp.name]
387 except KeyError:
388 logging.warning('unknown component %r in time-range filter',
389 comp.name)
390 return False
391 return component_handler(self.start, self.end, comp, tzify)
392
393 def match_indexes(self, indexes, tzify):
394 vs = {}
395 for name, value in indexes.items():
396 if name and name[2:] in self.all_props:
397 if value:
398 if not isinstance(value[0], vDDDTypes):
399 vs[name[2:]] = vDDDTypes(vDatetime.from_ical(value[0]))
400 else:
401 vs[name[2:]] = value[0]
402
403 try:
404 component_handler = self.component_handlers[self.comp]
405 except KeyError:
406 logging.warning('unknown component %r in time-range filter',
407 self.comp)
408 return False
409 return component_handler(self.start, self.end, vs, tzify)
410
411 def index_keys(self):
412 if self.comp == 'VEVENT':
413 props = ['DTSTART', 'DTEND', 'DURATION']
414 elif self.comp == 'VTODO':
415 props = ['DTSTART', 'DUE', 'DURATION', 'CREATED', 'COMPLETED']
416 elif self.comp == 'VJOURNAL':
417 props = ['DTSTART']
418 elif self.comp == 'VFREEBUSY':
419 props = ['DTSTART', 'DTEND', 'FREEBUSY']
420 elif self.comp == 'VALARM':
421 raise NotImplementedError
422 else:
423 props = self.all_props
424 return [['P=' + prop] for prop in props]
425
426
427 class TextMatcher(object):
428
429 def __init__(self, text, collation=None, negate_condition=False):
430 if isinstance(text, str):
431 text = text.encode()
432 self.text = text
433 if collation is None:
434 collation = 'i;ascii-casemap'
435 self.collation = _mod_collation.get_collation(collation)
436 self.negate_condition = negate_condition
437
438 def __repr__(self):
439 return '%s(%r, collation=%r, negate_condition=%r)' % (
440 self.__class__.__name__, self.text, self.collation,
441 self.negate_condition)
442
443 def match_indexes(self, indexes):
444 return any(self.match(k) for k in indexes[None])
445
446 def match(self, prop):
447 if isinstance(prop, vText):
448 prop = prop.encode()
449 matches = self.collation(self.text, prop)
450 if self.negate_condition:
451 return not matches
452 else:
453 return matches
454
455
456 class ComponentFilter(object):
457
458 def __init__(self, name, children=None, is_not_defined=False,
459 time_range=None):
460 self.name = name
461 self.children = children
462 self.is_not_defined = is_not_defined
463 self.time_range = time_range
464 self.children = children or []
465
466 def __repr__(self):
467 return '%s(%r, children=%r, is_not_defined=%r, time_range=%r)' % (
468 self.__class__.__name__, self.name, self.children,
469 self.is_not_defined, self.time_range)
470
471 def filter_subcomponent(self, name, is_not_defined=False,
472 time_range=None):
473 ret = ComponentFilter(
474 name=name, is_not_defined=is_not_defined, time_range=time_range)
475 self.children.append(ret)
476 return ret
477
478 def filter_property(self, name, is_not_defined=False, time_range=None):
479 ret = PropertyFilter(
480 name=name, is_not_defined=is_not_defined, time_range=time_range)
481 self.children.append(ret)
482 return ret
483
484 def filter_time_range(self, start, end):
485 self.time_range = ComponentTimeRangeMatcher(
486 start, end, comp=self.name)
487 return self.time_range
488
489 def match(self, comp, tzify):
490 # From https://tools.ietf.org/html/rfc4791, 9.7.1:
491 # A CALDAV:comp-filter is said to match if:
492
493 # 2. The CALDAV:comp-filter XML element contains a
494 # CALDAV:is-not-defined XML element and the calendar object or calendar
495 # component type specified by the "name" attribute does not exist in
496 # the current scope;
497 if self.is_not_defined:
498 return comp.name != self.name
499
500 # 1: The CALDAV:comp-filter XML element is empty and the calendar
501 # object or calendar component type specified by the "name" attribute
502 # exists in the current scope;
503 if comp.name != self.name:
504 return False
505
506 # 3. The CALDAV:comp-filter XML element contains a CALDAV:time-range
507 # XML element and at least one recurrence instance in the targeted
508 # calendar component is scheduled to overlap the specified time range
509 if (self.time_range is not None and
510 not self.time_range.match(comp, tzify)):
511 return False
512
513 # ... and all specified CALDAV:prop-filter and CALDAV:comp-filter child
514 # XML elements also match the targeted calendar component;
515 for child in self.children:
516 if isinstance(child, ComponentFilter):
517 if not any(child.match(c, tzify) for c in comp.subcomponents):
518 return False
519 elif isinstance(child, PropertyFilter):
520 if not child.match(comp, tzify):
521 return False
522 else:
523 raise TypeError(child)
524
525 return True
526
527 def _implicitly_defined(self):
528 return any(not getattr(child, 'is_not_defined', False)
529 for child in self.children)
530
531 def match_indexes(self, indexes, tzify):
532 myindex = 'C=' + self.name
533 if self.is_not_defined:
534 return not bool(indexes[myindex])
535
536 subindexes = create_subindexes(indexes, myindex)
537
538 if (self.time_range is not None and
539 not self.time_range.match_indexes(subindexes, tzify)):
540 return False
541
542 for child in self.children:
543 if not child.match_indexes(subindexes, tzify):
544 return False
545
546 if not self._implicitly_defined():
547 return bool(indexes[myindex])
548
549 return True
550
551 def index_keys(self):
552 mine = 'C=' + self.name
553 for child in (
554 self.children +
555 ([self.time_range] if self.time_range else [])):
556 for tl in child.index_keys():
557 yield [(mine + '/' + child_index) for child_index in tl]
558 if not self._implicitly_defined():
559 yield [mine]
560
561
562 class PropertyFilter(object):
563
564 def __init__(self, name, children=None, is_not_defined=False,
565 time_range=None):
566 self.name = name
567 self.is_not_defined = is_not_defined
568 self.children = children or []
569 self.time_range = time_range
570
571 def __repr__(self):
572 return '%s(%r, children=%r, is_not_defined=%r, time_range=%r)' % (
573 self.__class__.__name__, self.name, self.children,
574 self.is_not_defined, self.time_range)
575
576 def filter_parameter(self, name, is_not_defined=False):
577 ret = ParameterFilter(name=name, is_not_defined=is_not_defined)
578 self.children.append(ret)
579 return ret
580
581 def filter_time_range(self, start, end):
582 self.time_range = PropertyTimeRangeMatcher(start, end)
583 return self.time_range
584
585 def filter_text_match(self, text, collation=None, negate_condition=False):
586 ret = TextMatcher(
587 text, collation=collation, negate_condition=negate_condition)
588 self.children.append(ret)
589 return ret
590
591 def match(self, comp, tzify):
592 # From https://tools.ietf.org/html/rfc4791, 9.7.2:
593 # A CALDAV:comp-filter is said to match if:
594
595 # The CALDAV:prop-filter XML element contains a CALDAV:is-not-defined
596 # XML element and no property of the type specified by the "name"
597 # attribute exists in the enclosing calendar component;
598
599 if self.is_not_defined:
600 return self.name not in comp
601
602 try:
603 prop = comp[self.name]
604 except KeyError:
605 return False
606
607 if self.time_range and not self.time_range.match(prop, tzify):
608 return False
609
610 for child in self.children:
611 if not child.match(prop):
612 return False
613
614 return True
615
616 def match_indexes(self, indexes, tzify):
617 myindex = 'P=' + self.name
618 if self.is_not_defined:
619 return not bool(indexes[myindex])
620 subindexes = create_subindexes(indexes, myindex)
621 if not self.children and not self.time_range:
622 return bool(indexes[myindex])
623
624 if (self.time_range is not None and
625 not self.time_range.match_indexes(subindexes, tzify)):
626 return False
627
628 for child in self.children:
629 if not child.match_indexes(subindexes):
630 return False
631
632 return True
633
634 def index_keys(self):
635 mine = 'P=' + self.name
636 for child in self.children:
637 if not isinstance(child, ParameterFilter):
638 continue
639 for tl in child.index_keys():
640 yield [(mine + '/' + child_index) for child_index in tl]
641 yield [mine]
642
643
644 class ParameterFilter(object):
645
646 def __init__(self, name, children=None, is_not_defined=False):
647 self.name = name
648 self.is_not_defined = is_not_defined
649 self.children = children or []
650
651 def filter_text_match(self, text, collation=None, negate_condition=False):
652 ret = TextMatcher(
653 text, collation=collation, negate_condition=negate_condition)
654 self.children.append(ret)
655 return ret
656
657 def match(self, prop):
658 if self.is_not_defined:
659 return self.name not in prop.params
660
661 try:
662 value = prop.params[self.name].encode()
663 except KeyError:
664 return False
665
666 for child in self.children:
667 if not child.match(value):
668 return False
669 return True
670
671 def index_keys(self):
672 yield ['A=' + self.name]
673
674 def match_indexes(self, indexes):
675 myindex = 'A=' + self.name
676 if self.is_not_defined:
677 return not bool(indexes[myindex])
678
679 try:
680 value = indexes[myindex][0]
681 except IndexError:
682 return False
683
684 for child in self.children:
685 if not child.match(value):
686 return False
687 return True
688
689
690 class CalendarFilter(Filter):
691 """A filter that works on ICalendar files."""
692
693 def __init__(self, default_timezone):
694 self.tzify = lambda dt: as_tz_aware_ts(dt, default_timezone)
695 self.children = []
696
697 def filter_subcomponent(self, name, is_not_defined=False,
698 time_range=None):
699 ret = ComponentFilter(
700 name=name, is_not_defined=is_not_defined, time_range=time_range)
701 self.children.append(ret)
702 return ret
703
704 def check(self, name, file):
705 if file.content_type != 'text/calendar':
706 return False
707 c = file.calendar
708 if c is None:
709 return False
710
711 for child_filter in self.children:
712 try:
713 if not child_filter.match(file.calendar, self.tzify):
714 return False
715 except MissingProperty as e:
716 logging.warning(
717 'calendar_query: Ignoring calendar object %s, due '
718 'to missing property %s', name, e.property_name)
719 return False
720 return True
721
722 def check_from_indexes(self, name, indexes):
723 for child_filter in self.children:
724 if not child_filter.match_indexes(
725 indexes, self.tzify):
726 return False
727 return True
728
729 def index_keys(self):
730 subindexes = []
731 for child in self.children:
732 subindexes.extend(child.index_keys())
733 return subindexes
734
735 def __repr__(self):
736 return '%s(%r)' % (self.__class__.__name__, self.children)
737
738
202739 class ICalendarFile(File):
203740 """Handle for ICalendar files."""
204741
271808 except KeyError:
272809 pass
273810 raise KeyError
811
812 def _get_index(self, key):
813 todo = [(self.calendar, key.split('/'))]
814 rest = []
815 while todo:
816 (c, segments) = todo.pop(0)
817 if segments and segments[0].startswith('C='):
818 if c.name == segments[0][2:]:
819 if len(segments) > 1 and segments[1].startswith('C='):
820 todo.extend(
821 (comp, segments[1:]) for comp in c.subcomponents)
822 else:
823 rest.append((c, segments[1:]))
824
825 for c, segments in rest:
826 if not segments:
827 yield True
828 elif segments[0].startswith('P='):
829 assert len(segments) == 1
830 try:
831 yield c[segments[0][2:]]
832 except KeyError:
833 pass
834 else:
835 raise AssertionError('segments: %r' % segments)
836
837
838 def as_tz_aware_ts(dt, default_timezone):
839 if not getattr(dt, 'time', None):
840 dt = datetime.datetime.combine(dt, datetime.time())
841 if dt.tzinfo is None:
842 dt = dt.replace(tzinfo=default_timezone)
843 assert dt.tzinfo
844 return dt
2424
2525 import mimetypes
2626
27 from .index import IndexManager
28
2729 STORE_TYPE_ADDRESSBOOK = 'addressbook'
2830 STORE_TYPE_CALENDAR = 'calendar'
2931 STORE_TYPE_PRINCIPAL = 'principal'
4446
4547 DEFAULT_MIME_TYPE = 'application/octet-stream'
4648
49 PARANOID = False
50
4751
4852 class File(object):
4953 """A file type handler."""
96100 yield "Added " + item_description
97101 else:
98102 yield "Modified " + item_description
103
104 def _get_index(self, key):
105 """Obtain an index for this file.
106
107 :param key: Index key
108 :yield: Index values
109 """
110 raise NotImplementedError(self._get_index)
111
112 def get_indexes(self, keys):
113 """Obtain indexes for this file.
114
115 :param keys: Iterable of index keys
116 :return: Dictionary mapping key names to values
117 """
118 ret = {}
119 for k in keys:
120 ret[k] = list(self._get_index(k))
121 return ret
122
123
124 class Filter(object):
125 """A filter that can be used to query for certain resources.
126
127 Filters are often resource-type specific.
128 """
129
130 def check(self, name, resource):
131 """Check if this filter applies to a resource.
132
133 :param name: Name of the resource
134 :param resource: Resource object
135 :return: boolean
136 """
137 raise NotImplementedError(self.check)
138
139 def index_keys(self):
140 """Returns a list of indexes that could be used to apply this filter.
141
142 :return: AND-list of OR-options
143 """
144 raise NotImplementedError(self.index_keys)
145
146 def check_from_indexes(self, name, indexes):
147 """Check from a set of indexes whether a resource matches.
148
149 :param name: Name of the resource
150 :param indexes: Dictionary mapping index names to values
151 :return: boolean
152 """
153 raise NotImplementedError(self.check_from_indexes)
99154
100155
101156 def open_by_content_type(content, content_type, extra_file_handlers):
167222 class Store(object):
168223 """A object store."""
169224
170 def __init__(self):
225 def __init__(self, index):
171226 self.extra_file_handlers = {}
227 self.index = index
228 self.index_manager = IndexManager(self.index)
172229
173230 def load_extra_file_handler(self, file_handler):
174231 self.extra_file_handlers[file_handler.content_type] = file_handler
175232
176 def iter_with_etag(self):
233 def iter_with_etag(self, ctag=None):
177234 """Iterate over all items in the store with etag.
178235
236 :param ctag: Possible ctag to iterate for
179237 :yield: (name, content_type, etag) tuples
180238 """
181239 raise NotImplementedError(self.iter_with_etag)
240
241 def iter_with_filter(self, filter):
242 """Iterate over all items in the store that match a particular filter.
243
244 :param filter: Filter to apply
245 :yield: (name, file, etag) tuples
246 """
247 if self.index_manager is not None:
248 try:
249 necessary_keys = filter.index_keys()
250 except NotImplementedError:
251 pass
252 else:
253 present_keys = self.index_manager.find_present_keys(
254 necessary_keys)
255 if present_keys is not None:
256 return self._iter_with_filter_indexes(
257 filter, present_keys)
258 return self._iter_with_filter_naive(filter)
259
260 def _iter_with_filter_naive(self, filter):
261 for (name, content_type, etag) in self.iter_with_etag():
262 file = self.get_file(name, content_type, etag)
263 if filter.check(name, file):
264 yield (name, file, etag)
265
266 def _iter_with_filter_indexes(self, filter, keys):
267 for (name, content_type, etag) in self.iter_with_etag():
268 try:
269 file_values = self.index.get_values(name, etag, keys)
270 except KeyError:
271 # Index values not yet present for this file.
272 file = self.get_file(name, content_type, etag)
273 file_values = file.get_indexes(self.index.available_keys())
274 self.index.add_values(name, etag, file_values)
275 if filter.check_from_indexes(name, file_values):
276 yield (name, file, etag)
277 else:
278 if file_values is None:
279 continue
280 file = self.get_file(name, content_type, etag)
281 if PARANOID:
282 if file_values != file.get_indexes(keys):
283 raise AssertionError('%r != %r' % (
284 file_values, file.get_indexes(keys)))
285 if (filter.check_from_indexes(name, file_values) !=
286 filter.check(name, file)):
287 raise AssertionError(
288 'index based filter not matching real file filter')
289 if filter.check_from_indexes(name, file_values):
290 file = self.get_file(name, content_type, etag)
291 yield (name, file, etag)
182292
183293 def get_file(self, name, content_type=None, etag=None):
184294 """Get the contents of an object.
194304 self._get_raw(name, etag), content_type,
195305 extra_file_handlers=self.extra_file_handlers)
196306
197 def _get_raw(self, name, etag):
307 def _get_raw(self, name, etag=None):
198308 """Get the raw contents of an object.
199309
310 :param name: Filename
311 :param etag: Optional etag to return
200312 :return: raw contents
201313 """
202314 raise NotImplementedError(self._get_raw)
269381 """
270382 raise NotImplementedError(self.get_displayname)
271383
272 def set_displayname(self):
384 def set_displayname(self, displayname):
273385 """Set the display name of this store.
274386 """
275387 raise NotImplementedError(self.set_displayname)
2424 FILENAME = '.xandikos'
2525
2626
27 class CollectionConfig(object):
27 class CollectionMetadata(object):
28 """Metadata for a configuration."""
2829
29 def __init__(self, cp=None):
30 def get_color(self):
31 """Get the color for this collection.
32 """
33 raise NotImplementedError(self.get_color)
34
35 def set_color(self, color):
36 """Change the color of this collection."""
37 raise NotImplementedError(self.set_color)
38
39 def get_comment(self):
40 raise NotImplementedError(self.get_comment)
41
42 def get_displayname(self):
43 raise NotImplementedError(self.get_displayname)
44
45 def get_description(self):
46 raise NotImplementedError(self.get_description)
47
48 def get_order(self):
49 raise NotImplementedError(self.get_order)
50
51 def set_order(self, order):
52 raise NotImplementedError(self.set_order)
53
54
55 class FileBasedCollectionMetadata(CollectionMetadata):
56 """Metadata for a configuration."""
57
58 def __init__(self, cp=None, save=None):
3059 if cp is None:
3160 cp = configparser.ConfigParser()
3261 self._configparser = cp
62 self._save_cb = save
63
64 def _save(self, message):
65 if self._save_cb is None:
66 return
67 self._save_cb(self._configparser, message)
3368
3469 @classmethod
3570 def from_file(cls, f):
3671 cp = configparser.ConfigParser()
3772 cp.read_file(f)
38 return CollectionConfig(cp)
73 return cls(cp)
3974
4075 def get_color(self):
4176 return self._configparser['DEFAULT']['color']
4883
4984 def get_description(self):
5085 return self._configparser['DEFAULT']['description']
86
87 def set_color(self, color):
88 if color is not None:
89 self._configparser['DEFAULT']['color'] = color
90 else:
91 del self._configparser['DEFAULT']['color']
92 self._save("Set color.")
93
94 def set_displayname(self, displayname):
95 if displayname is not None:
96 self._configparser['DEFAULT']['displayname'] = displayname
97 else:
98 del self._configparser['DEFAULT']['displayname']
99 self._save("Set display name.")
100
101 def set_description(self, description):
102 if description is not None:
103 self._configparser['DEFAULT']['description'] = description
104 else:
105 del self._configparser['DEFAULT']['description']
106 self._save("Set description.")
107
108 def set_comment(self, comment):
109 if comment is not None:
110 self._configparser['DEFAULT']['comment'] = comment
111 else:
112 del self._configparser['DEFAULT']['comment']
113 self._save("Set comment.")
114
115 def set_type(self, store_type):
116 self._configparser['DEFAULT']['type'] = store_type
117 self._save("Set collection type.")
118
119 def get_type(self):
120 return self._configparser['DEFAULT']['type']
121
122 def get_order(self):
123 return self._configparser['calendar']['order']
124
125 def set_order(self, order):
126 try:
127 self._configparser.add_section('calendar')
128 except configparser.DuplicateSectionError:
129 pass
130 if order is None:
131 del self._configparser['calendar']['order']
132 else:
133 self._configparser['calendar']['order'] = order
1919 """Git store.
2020 """
2121
22 import configparser
23 from io import BytesIO, StringIO
2224 import logging
2325 import os
2426 import shutil
3840 open_by_content_type,
3941 open_by_extension,
4042 )
41 from .config import CollectionConfig
43 from .config import (
44 FILENAME as CONFIG_FILENAME,
45 CollectionMetadata,
46 FileBasedCollectionMetadata,
47 )
48 from .index import MemoryIndex
4249
4350
4451 from dulwich.file import GitFile
5865 logger = logging.getLogger(__name__)
5966
6067
68 class RepoCollectionMetadata(CollectionMetadata):
69
70 def __init__(self, repo):
71 self._repo = repo
72
73 @classmethod
74 def present(cls, repo):
75 config = repo.get_config()
76 return config.has_section((b'xandikos', ))
77
78 def get_color(self):
79 config = self._repo.get_config()
80 color = config.get(b'xandikos', b'color')
81 if color == b'':
82 raise KeyError
83 return color.decode(DEFAULT_ENCODING)
84
85 def set_color(self, color):
86 config = self._repo.get_config()
87 if color is not None:
88 config.set(
89 b'xandikos', b'color', color.encode(DEFAULT_ENCODING))
90 else:
91 # TODO(jelmer): Add and use config.remove()
92 config.set(b'xandikos', b'color', b'')
93 self._write_config(config)
94
95 def _write_config(self, config):
96 f = BytesIO()
97 config.write_to_file(f)
98 self._repo._put_named_file('config', f.getvalue())
99
100 def get_displayname(self):
101 config = self._repo.get_config()
102 displayname = config.get(b'xandikos', b'displayname')
103 if displayname == b'':
104 raise KeyError
105 return displayname.decode(DEFAULT_ENCODING)
106
107 def set_displayname(self, displayname):
108 config = self._repo.get_config()
109 if displayname is not None:
110 config.set(b'xandikos', b'displayname',
111 displayname.encode(DEFAULT_ENCODING))
112 else:
113 config.set(b'xandikos', b'displayname', b'')
114 self._write_config(config)
115
116 def get_description(self):
117 desc = self._repo.get_description()
118 if desc in (None, b''):
119 raise KeyError
120 return desc.decode(DEFAULT_ENCODING)
121
122 def set_description(self, description):
123 if description is not None:
124 self._repo.set_description(description.encode(DEFAULT_ENCODING))
125 else:
126 self._repo.set_description(b'')
127
128 def get_comment(self):
129 config = self._repo.get_config()
130 comment = config.get(b'xandikos', b'comment')
131 if comment == b'':
132 raise KeyError
133 return comment.decode(DEFAULT_ENCODING)
134
135 def set_comment(self, comment):
136 config = self._repo.get_config()
137 if comment is not None:
138 config.set(
139 b'xandikos', b'comment', comment.encode(DEFAULT_ENCODING))
140 else:
141 # TODO(jelmer): Add and use config.remove()
142 config.set(b'xandikos', b'comment', b'')
143 self._write_config(config)
144
145 def set_type(self, store_type):
146 config = self._repo.get_config()
147 config.set(b'xandikos', b'type', store_type.encode(DEFAULT_ENCODING))
148 self._write_config(config)
149
150 def get_type(self):
151 config = self._repo.get_config()
152 store_type = config.get(b'xandikos', b'type')
153 store_type = store_type.decode(DEFAULT_ENCODING)
154 if store_type not in VALID_STORE_TYPES:
155 logging.warning(
156 'Invalid store type %s set for %r.',
157 store_type, self.repo)
158 return store_type
159
160 def get_order(self):
161 config = self._repo.get_config()
162 order = config.get(b'xandikos', b'calendar-order')
163 if order == b'':
164 raise KeyError
165 return order.decode('utf-8')
166
167 def set_order(self, order):
168 config = self._repo.get_config()
169 if order is None:
170 order = ''
171 config.set(b'xandikos', b'calendar-order', order.encode('utf-8'))
172 self._write_config(config)
173
174
61175 class locked_index(object):
62176
63177 def __init__(self, path):
80194
81195 def __init__(self, repo, ref=b'refs/heads/master',
82196 check_for_duplicate_uids=True):
83 super(GitStore, self).__init__()
197 super(GitStore, self).__init__(MemoryIndex())
84198 self.ref = ref
85199 self.repo = repo
86200 # Maps uids to (sha, fname)
91205
92206 @property
93207 def config(self):
94 return CollectionConfig()
208 if RepoCollectionMetadata.present(self.repo):
209 return RepoCollectionMetadata(self.repo)
210 else:
211 cp = configparser.ConfigParser()
212 try:
213 cf = self._get_raw(CONFIG_FILENAME)
214 except KeyError:
215 pass
216 else:
217 if cf is not None:
218 cp.read_string(b''.join(cf).decode('utf-8'))
219
220 def save_config(cp, message):
221 f = StringIO()
222 cp.write(f)
223 self._import_one(
224 CONFIG_FILENAME, [f.getvalue().encode('utf-8')],
225 message)
226 return FileBasedCollectionMetadata(cp, save=save_config)
95227
96228 def __repr__(self):
97229 return "%s(%r, ref=%r)" % (type(self).__name__, self.repo, self.ref)
258390 try:
259391 return self.config.get_description()
260392 except KeyError:
261 desc = self.repo.get_description()
262 if desc is not None:
263 desc = desc.decode(DEFAULT_ENCODING)
264 return desc
393 return None
265394
266395 def set_description(self, description):
267396 """Set extended description.
268397
269398 :param description: repository description as string
270399 """
271 return self.repo.set_description(description.encode(DEFAULT_ENCODING))
400 self.config.set_description(description)
272401
273402 def set_comment(self, comment):
274403 """Set comment.
275404
276405 :param comment: Comment
277406 """
278 config = self.repo.get_config()
279 config.set(b'xandikos', b'comment', comment.encode(DEFAULT_ENCODING))
280 config.write_to_path()
407 self.config.set_comment(comment)
281408
282409 def get_comment(self):
283410 """Get comment.
287414 try:
288415 return self.config.get_comment()
289416 except KeyError:
290 config = self.repo.get_config()
291 try:
292 comment = config.get(b'xandikos', b'comment')
293 except KeyError:
294 return None
295 else:
296 return comment.decode(DEFAULT_ENCODING)
417 return None
297418
298419 def get_color(self):
299420 """Get color.
303424 try:
304425 return self.config.get_color()
305426 except KeyError:
306 config = self.repo.get_config()
307 try:
308 color = config.get(b'xandikos', b'color')
309 except KeyError:
310 return None
311 else:
312 return color.decode(DEFAULT_ENCODING)
427 return None
313428
314429 def set_color(self, color):
315430 """Set the color code for this store."""
316 config = self.repo.get_config()
317 # Strip leading # to work around
318 # https://github.com/jelmer/dulwich/issues/511
319 # TODO(jelmer): Drop when that bug gets fixed.
320 config.set(
321 b'xandikos', b'color',
322 color.lstrip('#').encode(DEFAULT_ENCODING) if color else b'')
323 config.write_to_path()
431 self.config.set_color(color)
324432
325433 def get_displayname(self):
326434 """Get display name.
330438 try:
331439 return self.config.get_displayname()
332440 except KeyError:
333 config = self.repo.get_config()
334 try:
335 displayname = config.get(b'xandikos', b'displayname')
336 except KeyError:
337 return None
338 else:
339 return displayname.decode(DEFAULT_ENCODING)
441 return None
340442
341443 def set_displayname(self, displayname):
342444 """Set the display name.
343445
344446 :param displayname: New display name
345447 """
346 config = self.repo.get_config()
347 config.set(b'xandikos', b'displayname',
348 displayname.encode(DEFAULT_ENCODING))
349 config.write_to_path()
448 self.config.set_displayname(displayname)
350449
351450 def set_type(self, store_type):
352451 """Set store type.
353452
354453 :param store_type: New store type (one of VALID_STORE_TYPES)
355454 """
356 config = self.repo.get_config()
357 config.set(b'xandikos', b'type', store_type.encode(DEFAULT_ENCODING))
358 config.write_to_path()
455 self.config.set_type(store_type)
359456
360457 def get_type(self):
361458 """Get store type.
362459
363460 This looks in git config first, then falls back to guessing.
364461 """
365 config = self.repo.get_config()
366 try:
367 store_type = config.get(b'xandikos', b'type')
462 try:
463 return self.config.get_type()
368464 except KeyError:
369465 return super(GitStore, self).get_type()
370 else:
371 store_type = store_type.decode(DEFAULT_ENCODING)
372 if store_type not in VALID_STORE_TYPES:
373 logging.warning(
374 'Invalid store type %s set for %r.',
375 store_type, self.repo)
376 return store_type
377466
378467 def iter_changes(self, old_ctag, new_ctag):
379468 """Get changes between two versions of this store.
439528 tree = self.repo.object_store[ctag.encode('ascii')]
440529 for (name, mode, sha) in tree.iteritems():
441530 name = name.decode(DEFAULT_ENCODING)
531 if name == CONFIG_FILENAME:
532 continue
442533 yield (name, mode, sha)
443534
444535 @classmethod
612703 tree = self.repo.object_store[ctag.encode('ascii')]
613704 for (name, mode, sha) in tree.iteritems():
614705 name = name.decode(DEFAULT_ENCODING)
706 if name == CONFIG_FILENAME:
707 continue
615708 yield (name, mode, sha)
616709 else:
617710 index = self.repo.open_index()
618711 for (name, sha, mode) in index.iterobjects():
619712 name = name.decode(DEFAULT_ENCODING)
713 if name == CONFIG_FILENAME:
714 continue
620715 yield (name, mode, sha)
621716
622717 def subdirectories(self):
0 # Xandikos
1 # Copyright (C) 2019 Jelmer Vernooij <jelmer@jelmer.uk>, et al.
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 3
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """Indexing.
20 """
21
22 import collections
23 import logging
24
25
26 INDEXING_THRESHOLD = 5
27
28
29 class Index(object):
30 """Index management."""
31
32 def available_keys(self):
33 """Return list of available index keys."""
34 raise NotImplementedError(self.available_indexes)
35
36 def get_values(self, name, etag, keys):
37 """Get the values for specified keys for a name."""
38 raise NotImplementedError(self.get_values)
39
40 def iter_etags(self):
41 """Return all the etags covered by this index."""
42 raise NotImplementedError(self.iter_etags)
43
44
45 class MemoryIndex(Index):
46
47 def __init__(self):
48 self._indexes = {}
49 self._in_index = set()
50
51 def available_keys(self):
52 return self._indexes.keys()
53
54 def get_values(self, name, etag, keys):
55 if etag not in self._in_index:
56 raise KeyError(etag)
57 indexes = {}
58 for k in keys:
59 if k not in self._indexes:
60 raise AssertionError
61 try:
62 indexes[k] = self._indexes[k][etag]
63 except KeyError:
64 indexes[k] = []
65 return indexes
66
67 def iter_etags(self):
68 return iter(self._in_index)
69
70 def add_values(self, name, etag, values):
71 for k, v in values.items():
72 if k not in self._indexes:
73 raise AssertionError
74 self._indexes[k][etag] = v
75 self._in_index.add(etag)
76
77 def reset(self, keys):
78 self._in_index = set()
79 self._indexes = {}
80 for key in keys:
81 self._indexes[key] = {}
82
83
84 class IndexManager(object):
85
86 def __init__(self, index, threshold=INDEXING_THRESHOLD):
87 self.index = index
88 self.desired = collections.defaultdict(lambda: 0)
89 self.indexing_threshold = threshold
90
91 def find_present_keys(self, necessary_keys):
92 available_keys = self.index.available_keys()
93 needed_keys = []
94 missing_keys = []
95 new_index_keys = set()
96 for keys in necessary_keys:
97 found = False
98 for key in keys:
99 if key in available_keys:
100 needed_keys.append(key)
101 found = True
102 if not found:
103 for key in keys:
104 self.desired[key] += 1
105 if self.desired[key] > self.indexing_threshold:
106 new_index_keys.add(key)
107 missing_keys.extend(keys)
108 if not missing_keys:
109 return needed_keys
110
111 if new_index_keys:
112 logging.debug('Adding new index keys: %r', new_index_keys)
113 self.index.reset(
114 set(self.index.available_keys()) | new_index_keys)
115
116 # TODO(jelmer): Maybe best to check if missing_keys are satisfiable
117 # now?
118
119 return None
2121 See https://github.com/pimutils/vdirsyncer/blob/master/docs/vdir.rst
2222 """
2323
24 import configparser
2425 import errno
2526 import hashlib
2627 import logging
3839 open_by_content_type,
3940 open_by_extension,
4041 )
41 from .config import CollectionConfig
42 from .config import (
43 FileBasedCollectionMetadata,
44 FILENAME as CONFIG_FILENAME,
45 )
46 from .index import MemoryIndex
4247
4348
4449 DEFAULT_ENCODING = 'utf-8'
5257 """
5358
5459 def __init__(self, path, check_for_duplicate_uids=True):
55 super(VdirStore, self).__init__()
60 super(VdirStore, self).__init__(MemoryIndex())
5661 self.path = path
5762 self._check_for_duplicate_uids = check_for_duplicate_uids
5863 # Set of blob ids that have already been scanned
5964 self._fname_to_uid = {}
6065 # Maps uids to (sha, fname)
6166 self._uid_to_fname = {}
62
63 @property
64 def config(self):
65 return CollectionConfig()
67 cp = configparser.ConfigParser()
68 cp.read([os.path.join(self.path, CONFIG_FILENAME)])
69
70 def save_config(cp, message):
71 with open(os.path.join(self.path, CONFIG_FILENAME), 'w') as f:
72 cp.write(f)
73 self.config = FileBasedCollectionMetadata(cp, save=save_config)
6674
6775 def __repr__(self):
6876 return "%s(%r)" % (type(self).__name__, self.path)
8492 """Get the raw contents of an object.
8593
8694 :param name: Name of the item
87 :param etag: Optional etag
95 :param etag: Optional etag (ignored)
8896 :return: raw contents as chunks
8997 """
90 if etag is None:
91 etag = self._get_etag(name)
9298 path = os.path.join(self.path, name)
9399 try:
94100 with open(path, 'rb') as f:
201207 for name in os.listdir(self.path):
202208 if name.endswith('.tmp'):
203209 continue
210 if name == CONFIG_FILENAME:
211 continue
204212 if name.endswith('.ics'):
205213 content_type = 'text/calendar'
206214 elif name.endswith('.vcf'):
232240
233241 :return: repository description as string
234242 """
235 raise NotImplementedError(self.get_description)
243 return self.config.get_description()
236244
237245 def set_description(self, description):
238246 """Set extended description.
239247
240248 :param description: repository description as string
241249 """
242 raise NotImplementedError(self.set_description)
250 self.config.set_description(description)
243251
244252 def set_comment(self, comment):
245253 """Set comment.
276284 :return: A Color code, or None
277285 """
278286 color = self._read_metadata('color')
279 assert color.startswith('#')
287 if color is not None:
288 assert color.startswith('#')
280289 return color
281290
282291 def set_color(self, color):
297306 :param displayname: New display name
298307 """
299308 self._write_metadata('displayname', displayname)
300
301 def set_type(self, store_type):
302 """Set store type.
303
304 :param store_type: New store type (one of VALID_STORE_TYPES)
305 """
306 raise NotImplementedError(self.set_type)
307
308 def get_type(self):
309 """Get store type.
310 """
311 raise NotImplementedError(self.get_type)
312309
313310 def iter_changes(self, old_ctag, new_ctag):
314311 """Get changes between two versions of this store.
9999 diff_iter = itertools.islice(diff_iter, nresults)
100100
101101 for (name, old_resource, new_resource) in diff_iter:
102 propstat = []
102 subhref = urllib.parse.urljoin(
103 webdav.ensure_trailing_slash(href), name)
103104 if new_resource is None:
104 for prop in requested:
105 propstat.append(
106 webdav.PropStatus('404 Not Found', None,
107 ET.Element(prop.tag)))
105 yield webdav.Status(subhref, status='404 Not Found')
108106 else:
107 propstat = []
109108 for prop in requested:
110109 if old_resource is not None:
111110 old_propstat = webdav.get_property_from_element(
116115 href, new_resource, properties, environ, prop)
117116 if old_propstat != new_propstat:
118117 propstat.append(new_propstat)
119 yield webdav.Status(
120 urllib.parse.urljoin(webdav.ensure_trailing_slash(href), name),
121 propstat=propstat)
118 yield webdav.Status(subhref, propstat=propstat)
122119 yield SyncToken(new_token)
123120
124121
99 <h2>Subcollections</h2>
1010
1111 <ul>
12 {% for name, resource in collection.members() %}
13 {% if '{DAV:}collection' in resource.resource_types %}
12 {% for name, resource in collection.subcollections() %}
1413 <li><a href="{{ urljoin(self_url+'/', name+'/') }}">{{ name }}</a></li>
15 {% endif %}
1614 {% endfor %}
1715 </ul>
1816
1111 <h2>Subcollections</h2>
1212
1313 <ul>
14 {% for name, resource in principal.members() %}
15 {% if '{DAV:}collection' in resource.resource_types %}
14 {% for name, resource in principal.subcollections() %}
1615 <li><a href="{{ urljoin(self_url+'/', name+'/') }}">{{ name }}</a></li>
17 {% endif %}
1816 {% endfor %}
1917 </ul>
2018
66
77 <p>Principals on this server:
88 <ul>
9 {% for name in principals %}
10 <li><a href="{{ urljoin(self_url+'/', name+'/') }}">{{ name }}</a></li>
9 {% for path in principals %}
10 <li><a href="{{ urljoin(self_url+'/', path.lstrip('/')+'/') }}">{{ path }}</a></li>
1111 {% endfor %}
1212 </ul>
1313 </p>
2323 names = [
2424 'api',
2525 'caldav',
26 'collectionconfig',
26 'config',
2727 'icalendar',
2828 'store',
2929 'webdav',
1616 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
1717 # MA 02110-1301, USA.
1818
19 import unittest
20
2119 from wsgiref.util import setup_testing_defaults
2220
23 from xandikos import caldav, davcommon
21 from xandikos import caldav
2422 from xandikos.webdav import Property, WebDAVApp, ET
2523
2624 from xandikos.tests import test_webdav
7169 code, headers, contents = self.mkcalendar(app, '/resource/bla')
7270 self.assertEqual('201 Created', code)
7371 self.assertEqual(b'', contents)
74
75
76 class ApplyTextMatchTest(unittest.TestCase):
77
78 def test_default_collation(self):
79 el = ET.Element('someel')
80 el.text = b"foobar"
81 self.assertTrue(caldav.apply_text_match(el, b"FOOBAR"))
82 self.assertTrue(caldav.apply_text_match(el, b"foobar"))
83 self.assertFalse(caldav.apply_text_match(el, b"fobar"))
84
85 def test_casecmp_collation(self):
86 el = ET.Element('someel')
87 el.set('collation', 'i;ascii-casemap')
88 el.text = b"foobar"
89 self.assertTrue(caldav.apply_text_match(el, b"FOOBAR"))
90 self.assertTrue(caldav.apply_text_match(el, b"foobar"))
91 self.assertFalse(caldav.apply_text_match(el, b"fobar"))
92
93 def test_cmp_collation(self):
94 el = ET.Element('someel')
95 el.text = b"foobar"
96 el.set('collation', 'i;octet')
97 self.assertFalse(caldav.apply_text_match(el, b"FOOBAR"))
98 self.assertTrue(caldav.apply_text_match(el, b"foobar"))
99 self.assertFalse(caldav.apply_text_match(el, b"fobar"))
100
101 def test_unknown_collation(self):
102 el = ET.Element('someel')
103 el.set('collation', 'i;blah')
104 el.text = b"foobar"
105 self.assertRaises(davcommon.UnknownCollation,
106 caldav.apply_text_match, el, b"FOOBAR")
+0
-54
xandikos/tests/test_collectionconfig.py less more
0 # Xandikos
1 # Copyright (C) 2016-2017 Jelmer Vernooij <jelmer@jelmer.uk>, et al.
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 3
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 import configparser
20 import unittest
21
22 from xandikos.store.config import CollectionConfig
23
24
25 class CollectionConfigTest(unittest.TestCase):
26
27 def test_get_color(self):
28 cp = configparser.ConfigParser()
29 c = CollectionConfig(cp)
30 self.assertRaises(KeyError, c.get_color)
31 cp['DEFAULT']['color'] = '040404'
32 self.assertEqual('040404', c.get_color())
33
34 def test_get_comment(self):
35 cp = configparser.ConfigParser()
36 c = CollectionConfig(cp)
37 self.assertRaises(KeyError, c.get_comment)
38 cp['DEFAULT']['comment'] = 'foo'
39 self.assertEqual('foo', c.get_comment())
40
41 def test_get_displayname(self):
42 cp = configparser.ConfigParser()
43 c = CollectionConfig(cp)
44 self.assertRaises(KeyError, c.get_displayname)
45 cp['DEFAULT']['displayname'] = 'foo'
46 self.assertEqual('foo', c.get_displayname())
47
48 def test_get_description(self):
49 cp = configparser.ConfigParser()
50 c = CollectionConfig(cp)
51 self.assertRaises(KeyError, c.get_description)
52 cp['DEFAULT']['description'] = 'foo'
53 self.assertEqual('foo', c.get_description())
0 # Xandikos
1 # Copyright (C) 2018 Jelmer Vernooij <jelmer@jelmer.uk>, et al.
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 3
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """Tests for xandikos.store.config."""
20
21 from io import StringIO
22
23 from unittest import TestCase
24
25 from ..store.config import FileBasedCollectionMetadata
26
27
28 class FileBasedCollectionMetadataTests(TestCase):
29
30 def test_get_color(self):
31 f = StringIO("""\
32 [DEFAULT]
33 color = #ffffff
34 """)
35 cc = FileBasedCollectionMetadata.from_file(f)
36 self.assertEqual('#ffffff', cc.get_color())
37
38 def test_get_color_missing(self):
39 f = StringIO("")
40 cc = FileBasedCollectionMetadata.from_file(f)
41 self.assertRaises(KeyError, cc.get_color)
42
43 def test_get_comment(self):
44 f = StringIO("""\
45 [DEFAULT]
46 comment = this is a comment
47 """)
48 cc = FileBasedCollectionMetadata.from_file(f)
49 self.assertEqual('this is a comment', cc.get_comment())
50
51 def test_get_comment_missing(self):
52 f = StringIO("")
53 cc = FileBasedCollectionMetadata.from_file(f)
54 self.assertRaises(KeyError, cc.get_comment)
55
56 def test_get_description(self):
57 f = StringIO("""\
58 [DEFAULT]
59 description = this is a description
60 """)
61 cc = FileBasedCollectionMetadata.from_file(f)
62 self.assertEqual('this is a description', cc.get_description())
63
64 def test_get_description_missing(self):
65 f = StringIO("")
66 cc = FileBasedCollectionMetadata.from_file(f)
67 self.assertRaises(KeyError, cc.get_description)
68
69 def test_get_displayname(self):
70 f = StringIO("""\
71 [DEFAULT]
72 displayname = DISPLAY-NAME
73 """)
74 cc = FileBasedCollectionMetadata.from_file(f)
75 self.assertEqual('DISPLAY-NAME', cc.get_displayname())
76
77 def test_get_displayname_missing(self):
78 f = StringIO("")
79 cc = FileBasedCollectionMetadata.from_file(f)
80 self.assertRaises(KeyError, cc.get_displayname)
81
82
83 class MetadataTests(object):
84
85 def test_color(self):
86 self.assertRaises(KeyError, self._config.get_color)
87 self._config.set_color('#ffffff')
88 self.assertEqual('#ffffff', self._config.get_color())
89 self._config.set_color(None)
90 self.assertRaises(KeyError, self._config.get_color)
91
92 def test_comment(self):
93 self.assertRaises(KeyError, self._config.get_comment)
94 self._config.set_comment('this is a comment')
95 self.assertEqual('this is a comment', self._config.get_comment())
96 self._config.set_comment(None)
97 self.assertRaises(KeyError, self._config.get_comment)
98
99 def test_displayname(self):
100 self.assertRaises(KeyError, self._config.get_displayname)
101 self._config.set_displayname('DiSpLaYName')
102 self.assertEqual('DiSpLaYName', self._config.get_displayname())
103 self._config.set_displayname(None)
104 self.assertRaises(KeyError, self._config.get_displayname)
105
106 def test_description(self):
107 self.assertRaises(KeyError, self._config.get_description)
108 self._config.set_description('this is a description')
109 self.assertEqual(
110 'this is a description', self._config.get_description())
111 self._config.set_description(None)
112 self.assertRaises(KeyError, self._config.get_description)
113
114 def test_order(self):
115 self.assertRaises(KeyError, self._config.get_order)
116 self._config.set_order('this is a order')
117 self.assertEqual('this is a order', self._config.get_order())
118 self._config.set_order(None)
119 self.assertRaises(KeyError, self._config.get_order)
120
121
122 class FileMetadataTests(TestCase, MetadataTests):
123
124 def setUp(self):
125 super(FileMetadataTests, self).setUp()
126 self._config = FileBasedCollectionMetadata()
127
128
129 class RepoMetadataTests(TestCase, MetadataTests):
130
131 def setUp(self):
132 super(RepoMetadataTests, self).setUp()
133 import dulwich.repo
134 from ..store.git import RepoCollectionMetadata
135 self._repo = dulwich.repo.MemoryRepo()
136 self._config = RepoCollectionMetadata(self._repo)
1818
1919 """Tests for xandikos.icalendar."""
2020
21 from datetime import datetime
22
23 import pytz
2124 import unittest
2225
23 from xandikos.icalendar import ICalendarFile, validate_calendar
26 from icalendar.cal import Event
27
28 from xandikos import (
29 collation as _mod_collation,
30 )
31 from xandikos.icalendar import (
32 CalendarFilter,
33 ICalendarFile,
34 MissingProperty,
35 TextMatcher,
36 validate_calendar,
37 apply_time_range_vevent,
38 as_tz_aware_ts,
39 )
2440 from xandikos.store import InvalidFileContents
2541
2642 EXAMPLE_VCALENDAR1 = b"""\
3854 END:VCALENDAR
3955 """
4056
57 EXAMPLE_VCALENDAR_WITH_PARAM = b"""\
58 BEGIN:VCALENDAR
59 VERSION:2.0
60 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN
61 BEGIN:VTODO
62 CREATED;TZID=America/Denver:20150314T223512Z
63 DTSTAMP:20150527T221952Z
64 LAST-MODIFIED:20150314T223512Z
65 STATUS:NEEDS-ACTION
66 SUMMARY:do something
67 UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff
68 END:VTODO
69 END:VCALENDAR
70 """
71
4172 EXAMPLE_VCALENDAR_NO_UID = b"""\
4273 BEGIN:VCALENDAR
4374 VERSION:2.0
91122 self.assertRaises(InvalidFileContents, fi.validate)
92123 self.assertEqual(["Invalid character b'\\\\x0c' in field SUMMARY"],
93124 list(validate_calendar(fi.calendar, strict=False)))
125
126
127 class CalendarFilterTests(unittest.TestCase):
128
129 def setUp(self):
130 self.cal = ICalendarFile([EXAMPLE_VCALENDAR1], 'text/calendar')
131
132 def test_simple_comp_filter(self):
133 filter = CalendarFilter(None)
134 filter.filter_subcomponent('VCALENDAR').filter_subcomponent('VEVENT')
135 self.assertEqual(filter.index_keys(), [['C=VCALENDAR/C=VEVENT']])
136 self.assertEqual(
137 self.cal.get_indexes(
138 ['C=VCALENDAR/C=VEVENT', 'C=VCALENDAR/C=VTODO']),
139 {'C=VCALENDAR/C=VEVENT': [], 'C=VCALENDAR/C=VTODO': [True]})
140 self.assertFalse(
141 filter.check_from_indexes(
142 'file', {'C=VCALENDAR/C=VEVENT': [],
143 'C=VCALENDAR/C=VTODO': [True]}))
144 self.assertFalse(filter.check('file', self.cal))
145 filter = CalendarFilter(None)
146 filter.filter_subcomponent('VCALENDAR').filter_subcomponent('VTODO')
147 self.assertTrue(filter.check('file', self.cal))
148 self.assertTrue(
149 filter.check_from_indexes(
150 'file', {'C=VCALENDAR/C=VEVENT': [],
151 'C=VCALENDAR/C=VTODO': [True]}))
152
153 def test_simple_comp_missing_filter(self):
154 filter = CalendarFilter(None)
155 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
156 'VTODO', is_not_defined=True)
157 self.assertEqual(
158 filter.index_keys(), [['C=VCALENDAR/C=VTODO'], ['C=VCALENDAR']])
159 self.assertFalse(
160 filter.check_from_indexes(
161 'file', {
162 'C=VCALENDAR': [True],
163 'C=VCALENDAR/C=VEVENT': [],
164 'C=VCALENDAR/C=VTODO': [True]}))
165 self.assertFalse(filter.check('file', self.cal))
166 filter = CalendarFilter(None)
167 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
168 'VEVENT', is_not_defined=True)
169 self.assertTrue(filter.check('file', self.cal))
170 self.assertTrue(
171 filter.check_from_indexes(
172 'file', {
173 'C=VCALENDAR': [True],
174 'C=VCALENDAR/C=VEVENT': [],
175 'C=VCALENDAR/C=VTODO': [True]}))
176
177 def test_prop_presence_filter(self):
178 filter = CalendarFilter(None)
179 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
180 'VTODO').filter_property('X-SUMMARY')
181 self.assertEqual(
182 filter.index_keys(),
183 [['C=VCALENDAR/C=VTODO/P=X-SUMMARY']])
184 self.assertFalse(
185 filter.check_from_indexes(
186 'file', {'C=VCALENDAR/C=VTODO/P=X-SUMMARY': []}))
187 self.assertFalse(filter.check('file', self.cal))
188 filter = CalendarFilter(None)
189 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
190 'VTODO').filter_property('SUMMARY')
191 self.assertTrue(
192 filter.check_from_indexes(
193 'file', {'C=VCALENDAR/C=VTODO/P=SUMMARY': [b'do something']}))
194 self.assertTrue(filter.check('file', self.cal))
195
196 def test_prop_explicitly_missing_filter(self):
197 filter = CalendarFilter(None)
198 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
199 'VEVENT').filter_property('X-SUMMARY', is_not_defined=True)
200 self.assertEqual(
201 filter.index_keys(),
202 [['C=VCALENDAR/C=VEVENT/P=X-SUMMARY'], ['C=VCALENDAR/C=VEVENT']])
203 self.assertFalse(
204 filter.check_from_indexes(
205 'file',
206 {'C=VCALENDAR/C=VEVENT/P=X-SUMMARY': [],
207 'C=VCALENDAR/C=VEVENT': []}))
208 self.assertFalse(filter.check('file', self.cal))
209 filter = CalendarFilter(None)
210 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
211 'VTODO').filter_property('X-SUMMARY', is_not_defined=True)
212 self.assertTrue(
213 filter.check_from_indexes(
214 'file', {
215 'C=VCALENDAR/C=VTODO/P=X-SUMMARY': [],
216 'C=VCALENDAR/C=VTODO': [True]}))
217 self.assertTrue(filter.check('file', self.cal))
218
219 def test_prop_text_match(self):
220 filter = CalendarFilter(None)
221 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
222 'VTODO').filter_property('SUMMARY').filter_text_match(
223 b'do something different')
224 self.assertEqual(
225 filter.index_keys(),
226 [['C=VCALENDAR/C=VTODO/P=SUMMARY']])
227 self.assertFalse(
228 filter.check_from_indexes(
229 'file', {'C=VCALENDAR/C=VTODO/P=SUMMARY': [b'do something']}))
230 self.assertFalse(filter.check('file', self.cal))
231 filter = CalendarFilter(None)
232 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
233 'VTODO').filter_property('SUMMARY').filter_text_match(
234 b'do something')
235 self.assertTrue(
236 filter.check_from_indexes(
237 'file', {'C=VCALENDAR/C=VTODO/P=SUMMARY': [b'do something']}))
238 self.assertTrue(filter.check('file', self.cal))
239
240 def test_param_text_match(self):
241 self.cal = ICalendarFile(
242 [EXAMPLE_VCALENDAR_WITH_PARAM], 'text/calendar')
243 filter = CalendarFilter(None)
244 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
245 'VTODO').filter_property('CREATED').filter_parameter(
246 'TZID').filter_text_match(
247 b'America/Blah')
248 self.assertEqual(
249 filter.index_keys(),
250 [['C=VCALENDAR/C=VTODO/P=CREATED/A=TZID'],
251 ['C=VCALENDAR/C=VTODO/P=CREATED']])
252 self.assertFalse(
253 filter.check_from_indexes(
254 'file',
255 {'C=VCALENDAR/C=VTODO/P=CREATED/A=TZID': [b'America/Denver']}))
256 self.assertFalse(filter.check('file', self.cal))
257 filter = CalendarFilter(None)
258 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
259 'VTODO').filter_property('CREATED').filter_parameter(
260 'TZID').filter_text_match(
261 b'America/Denver')
262 self.assertTrue(
263 filter.check_from_indexes(
264 'file',
265 {'C=VCALENDAR/C=VTODO/P=CREATED/A=TZID': [b'America/Denver']}))
266 self.assertTrue(filter.check('file', self.cal))
267
268 def _tzify(self, dt):
269 return as_tz_aware_ts(dt, pytz.utc)
270
271 def test_prop_apply_time_range(self):
272 filter = CalendarFilter(self._tzify)
273 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
274 'VTODO').filter_property('CREATED').filter_time_range(
275 self._tzify(datetime(2019, 3, 10, 22, 35, 12)),
276 self._tzify(datetime(2019, 3, 18, 22, 35, 12)))
277 self.assertEqual(
278 filter.index_keys(),
279 [['C=VCALENDAR/C=VTODO/P=CREATED']])
280 self.assertFalse(
281 filter.check_from_indexes(
282 'file',
283 {'C=VCALENDAR/C=VTODO/P=CREATED': ['20150314T223512Z']}))
284 self.assertFalse(filter.check('file', self.cal))
285 filter = CalendarFilter(self._tzify)
286 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
287 'VTODO').filter_property('CREATED').filter_time_range(
288 self._tzify(datetime(2015, 3, 10, 22, 35, 12)),
289 self._tzify(datetime(2015, 3, 18, 22, 35, 12)))
290 self.assertTrue(
291 filter.check_from_indexes(
292 'file',
293 {'C=VCALENDAR/C=VTODO/P=CREATED': ['20150314T223512Z']}))
294 self.assertTrue(filter.check('file', self.cal))
295
296 def test_comp_apply_time_range(self):
297 filter = CalendarFilter(self._tzify)
298 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
299 'VTODO').filter_time_range(
300 self._tzify(datetime(2015, 3, 3, 22, 35, 12)),
301 self._tzify(datetime(2015, 3, 10, 22, 35, 12)))
302 self.assertEqual(
303 filter.index_keys(),
304 [['C=VCALENDAR/C=VTODO/P=DTSTART'],
305 ['C=VCALENDAR/C=VTODO/P=DUE'],
306 ['C=VCALENDAR/C=VTODO/P=DURATION'],
307 ['C=VCALENDAR/C=VTODO/P=CREATED'],
308 ['C=VCALENDAR/C=VTODO/P=COMPLETED'],
309 ['C=VCALENDAR/C=VTODO']])
310 self.assertFalse(
311 filter.check_from_indexes(
312 'file',
313 {'C=VCALENDAR/C=VTODO/P=CREATED': ['20150314T223512Z'],
314 'C=VCALENDAR/C=VTODO': [True],
315 'C=VCALENDAR/C=VTODO/P=DUE': [],
316 'C=VCALENDAR/C=VTODO/P=DURATION': [],
317 'C=VCALENDAR/C=VTODO/P=COMPLETED': [],
318 'C=VCALENDAR/C=VTODO/P=DTSTART': [],
319 }))
320 self.assertFalse(filter.check('file', self.cal))
321 filter = CalendarFilter(self._tzify)
322 filter.filter_subcomponent('VCALENDAR').filter_subcomponent(
323 'VTODO').filter_time_range(
324 self._tzify(datetime(2015, 3, 10, 22, 35, 12)),
325 self._tzify(datetime(2015, 3, 18, 22, 35, 12)))
326 self.assertTrue(
327 filter.check_from_indexes(
328 'file',
329 {'C=VCALENDAR/C=VTODO/P=CREATED': ['20150314T223512Z'],
330 'C=VCALENDAR/C=VTODO': [True],
331 'C=VCALENDAR/C=VTODO/P=DUE': [],
332 'C=VCALENDAR/C=VTODO/P=DURATION': [],
333 'C=VCALENDAR/C=VTODO/P=COMPLETED': [],
334 'C=VCALENDAR/C=VTODO/P=DTSTART': [],
335 }))
336 self.assertTrue(filter.check('file', self.cal))
337
338
339 class TextMatchTest(unittest.TestCase):
340
341 def test_default_collation(self):
342 tm = TextMatcher(b"foobar")
343