Code Repositories xandikos / 53c9bb1
Merge support for indexes. Jelmer Vernooij 3 months ago
18 changed file(s) with 1519 addition(s) and 345 deletion(s). Raw diff Collapse all Expand all
0 Indexes
1 =======
0 Filter Performance
1 ==================
22
33 There are several API calls that would be good to speed up. In particular,
44 querying an entire calendar with filters is quite slow because it involves
55 scanning all the items.
6
7 Common Filters
8 ~~~~~~~~~~~~~~
69
710 There are a couple of common filters:
811
1013 Property filters that filter for a specific UID
1114 Property filters that filter for another property
1215 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.
1320
1421 Indexes are an implementation detail of the Store. This is necessary so that
15 e.g. the Git stores can take advantage that they have a tree hash.
22 e.g. the Git stores can take advantage of the fact that they have a tree hash.
1623
1724 One option would be to serialize the filter and then to keep a list of results
1825 per (tree_id, filter_hash). Unfortunately this by itself is not enough, since
2330 * Have some pre-set indexes. Perhaps components, and UID?
2431 * Cache but use the rightmost value as a key in a dict
2532 * 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
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 CalendarFilter,
29 apply_time_range_vevent,
30 as_tz_aware_ts,
31 )
2732 from icalendar.cal import component_factory, Calendar as ICalendar, FreeBusy
2833 from icalendar.prop import vDDDTypes, vPeriod, LocalTimezone
2934
5156 TRANSPARENCY_OPAQUE = 'opaque'
5257
5358
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
6259 class Calendar(webdav.Collection):
6360
6461 resource_types = (webdav.Collection.resource_types +
152149 Possible values are TRANSPARENCY_TRANSPARENT and TRANSPARENCY_OPAQUE
153150 """
154151 return TRANSPARENCY_OPAQUE
152
153 def calendar_query(self, create_filter_fn):
154 """Query for all the members of this calendar that match `filter`.
155
156 This is a naive implementation; subclasses should ideally provide
157 their own implementation that is faster.
158
159 :param create_filter_fn: Callback that constructs a
160 filter; takes a filter building class.
161 :return: Iterator over name, resource objects
162 """
163 raise NotImplementedError(self.calendar_query)
155164
156165
157166 class CalendarHomeSet(object):
296305 data_property = CalendarDataProperty()
297306
298307
299 def apply_prop_filter(el, comp, tzify):
308 def parse_prop_filter(el, cls):
300309 name = el.get('name')
310
301311 # From https://tools.ietf.org/html/rfc4791, 9.7.2:
302312 # A CALDAV:comp-filter is said to match if:
303313
304 # The CALDAV:prop-filter XML element contains a CALDAV:is-not-defined XML
305 # element and no property of the type specified by the "name" attribute
306 # exists in the enclosing calendar component;
307 if (
308 len(el) == 1 and
309 el[0].tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined'
310 ):
311 return name not in comp
312
313 try:
314 prop = comp[name]
315 except KeyError:
316 return False
314 prop_filter = cls(name=name)
317315
318316 for subel in el:
319 if subel.tag == '{urn:ietf:params:xml:ns:caldav}time-range':
320 if not apply_time_range_prop(subel, prop, tzify):
321 return False
317 if subel.tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined':
318 prop_filter.is_not_defined = True
319 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}time-range':
320 parse_time_range(subel, prop_filter.filter_time_range)
322321 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}text-match':
323 if not apply_text_match(subel, prop):
324 return False
322 parse_text_match(subel, prop_filter.filter_text_match)
325323 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}param-filter':
326 if not apply_param_filter(subel, prop):
327 return False
328 return True
329
330
331 def apply_text_match(el, value):
324 parse_param_filter(subel, prop_filter.filter_parameter)
325 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined':
326 pass
327 else:
328 raise AssertionError("unknown subelement %r" % subel.tag)
329 return prop_filter
330
331
332 def parse_text_match(el, cls):
332333 collation = el.get('collation', 'i;ascii-casemap')
333334 negate_condition = el.get('negate-condition', 'no')
334 matches = davcommon.get_collation(collation)(el.text, value)
335
336 if negate_condition == 'yes':
337 return (not matches)
338 else:
339 return matches
340
341
342 def apply_param_filter(el, prop):
335
336 return cls(
337 el.text, collation=collation,
338 negate_condition=(negate_condition == 'yes'))
339
340
341 def parse_param_filter(el, cls):
343342 name = el.get('name')
344 if (
345 len(el) == 1 and
346 el[0].tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined'
347 ):
348 return name not in prop.params
349
350 try:
351 value = prop.params[name]
352 except KeyError:
353 return False
343
344 param_filter = cls(name=name)
354345
355346 for subel in el:
356 if subel.tag == '{urn:ietf:params:xml:ns:caldav}text-match':
357 if not apply_text_match(subel, value):
358 return False
347 if subel.tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined':
348 param_filter.is_not_defined = True
349 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}text-match':
350 parse_text_match(subel, param_filter.filter_time_range)
359351 else:
360352 raise AssertionError('unknown tag %r in param-filter', subel.tag)
361 return True
353 return param_filter
362354
363355
364356 def _parse_time_range(el):
379371 return (start, end)
380372
381373
382 def as_tz_aware_ts(dt, default_timezone):
383 if not getattr(dt, 'time', None):
384 dt = datetime.datetime.combine(dt, datetime.time())
385 if dt.tzinfo is None:
386 dt = dt.replace(tzinfo=default_timezone)
387 assert dt.tzinfo
388 return dt
389
390
391 def apply_time_range_vevent(start, end, comp, tzify):
392 if 'DTSTART' not in comp:
393 raise MissingProperty('DTSTART')
394
395 if not (end > tzify(comp['DTSTART'].dt)):
396 return False
397
398 if 'DTEND' in comp:
399 if tzify(comp['DTEND'].dt) < tzify(comp['DTSTART'].dt):
400 logging.debug('Invalid DTEND < DTSTART')
401 return (start < tzify(comp['DTEND'].dt))
402
403 if 'DURATION' in comp:
404 return (start < tzify(comp['DTSTART'].dt) + comp['DURATION'].dt)
405 if getattr(comp['DTSTART'].dt, 'time', None) is not None:
406 return (start <= tzify(comp['DTSTART'].dt))
407 else:
408 return (start < (tzify(comp['DTSTART'].dt) + datetime.timedelta(1)))
409
410
411 def apply_time_range_vjournal(start, end, comp, tzify):
412 if 'DTSTART' not in comp:
413 return False
414
415 if not (end > tzify(comp['DTSTART'].dt)):
416 return False
417
418 if getattr(comp['DTSTART'].dt, 'time', None) is not None:
419 return (start <= tzify(comp['DTSTART'].dt))
420 else:
421 return (start < (tzify(comp['DTSTART'].dt) + datetime.timedelta(1)))
422
423
424 def apply_time_range_vtodo(start, end, comp, tzify):
425 if 'DTSTART' in comp:
426 if 'DURATION' in comp and 'DUE' not in comp:
427 return (
428 start <= tzify(comp['DTSTART'].dt) + comp['DURATION'].dt and
429 (end > tzify(comp['DTSTART'].dt) or
430 end >= tzify(comp['DTSTART'].dt) + comp['DURATION'].dt)
431 )
432 elif 'DUE' in comp and 'DURATION' not in comp:
433 return (
434 (start <= tzify(comp['DTSTART'].dt) or
435 start < tzify(comp['DUE'].dt)) and
436 (end > tzify(comp['DTSTART'].dt) or
437 end < tzify(comp['DUE'].dt))
438 )
439 else:
440 return (start <= tzify(comp['DTSTART'].dt) and
441 end > tzify(comp['DTSTART'].dt))
442 elif 'DUE' in comp:
443 return start < tzify(comp['DUE'].dt) and end >= tzify(comp['DUE'].dt)
444 elif 'COMPLETED' in comp:
445 if 'CREATED' in comp:
446 return (
447 (start <= tzify(comp['CREATED'].dt) or
448 start <= tzify(comp['COMPLETED'].dt)) and
449 (end >= tzify(comp['CREATED'].dt) or
450 end >= tzify(comp['COMPLETED'].dt))
451 )
452 else:
453 return (
454 start <= tzify(comp['COMPLETED'].dt) and
455 end >= tzify(comp['COMPLETED'].dt)
456 )
457 elif 'CREATED' in comp:
458 return end >= tzify(comp['CREATED'].dt)
459 else:
460 return True
461
462
463 def apply_time_range_vfreebusy(start, end, comp, tzify):
464 if 'DTSTART' in comp and 'DTEND' in comp:
465 return (
466 start <= tzify(comp['DTEND'].dt) and
467 end > tzify(comp['DTEND'].dt)
468 )
469
470 for period in comp.get('FREEBUSY', []):
471 if start < period.end and end > period.start:
472 return True
473
474 return False
475
476
477 def apply_time_range_valarm(start, end, comp, tzify):
478 raise NotImplementedError(apply_time_range_valarm)
479
480
481 def apply_time_range_comp(el, comp, tzify):
482 # According to https://tools.ietf.org/html/rfc4791, section 9.9 these are
483 # the properties to check.
374 def parse_time_range(el, cls):
484375 (start, end) = _parse_time_range(el)
485 component_handlers = {
486 'VEVENT': apply_time_range_vevent,
487 'VTODO': apply_time_range_vtodo,
488 'VJOURNAL': apply_time_range_vjournal,
489 'VFREEBUSY': apply_time_range_vfreebusy,
490 'VALARM': apply_time_range_valarm}
491 try:
492 component_handler = component_handlers[comp.name]
493 except KeyError:
494 logging.warning('unknown component %r in time-range filter',
495 comp.name)
496 return False
497 return component_handler(start, end, comp, tzify)
498
499
500 def apply_time_range_prop(el, val, tzify):
501 (start, end) = _parse_time_range(el)
502 raise NotImplementedError(apply_time_range_prop)
503
504
505 def apply_comp_filter(el, comp, tzify):
376 return cls(start, end)
377
378
379 def parse_comp_filter(el, cls):
506380 """Compile a comp-filter element into a Python function.
507381 """
508382 name = el.get('name')
383
509384 # From https://tools.ietf.org/html/rfc4791, 9.7.1:
510385 # A CALDAV:comp-filter is said to match if:
511386
512 # 2. The CALDAV:comp-filter XML element contains a CALDAV:is-not-defined
513 # XML element and the calendar object or calendar component type specified
514 # by the "name" attribute does not exist in the current scope;
515 if (
516 len(el) == 1 and
517 el[0].tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined'
518 ):
519 return comp.name != name
520
521 # 1: The CALDAV:comp-filter XML element is empty and the calendar object or
522 # calendar component type specified by the "name" attribute exists in the
523 # current scope;
524 if comp.name != name:
525 return False
387 comp_filter = cls(name=name)
526388
527389 # 3. The CALDAV:comp-filter XML element contains a CALDAV:time-range XML
528390 # element and at least one recurrence instance in the targeted calendar
530392 # specified CALDAV:prop-filter and CALDAV:comp-filter child XML elements
531393 # also match the targeted calendar component;
532394 for subel in el:
395 if subel.tag == '{urn:ietf:params:xml:ns:caldav}is-not-defined':
396 comp_filter.is_not_defined = True
533397 if subel.tag == '{urn:ietf:params:xml:ns:caldav}comp-filter':
534 if not any(apply_comp_filter(subel, c, tzify)
535 for c in comp.subcomponents):
536 return False
398 parse_comp_filter(subel, comp_filter.filter_subcomponent)
537399 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}prop-filter':
538 if not apply_prop_filter(subel, comp, tzify):
539 return False
400 parse_prop_filter(subel, comp_filter.filter_property)
540401 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}time-range':
541 if not apply_time_range_comp(subel, comp, tzify):
542 return False
402 parse_time_range(subel, comp_filter.filter_time_range)
543403 else:
544404 raise AssertionError('unknown filter tag %r' % subel.tag)
545 return True
405 return comp_filter
406
407
408 def parse_filter(filter_el, cls):
409 for subel in filter_el:
410 if subel.tag == '{urn:ietf:params:xml:ns:caldav}comp-filter':
411 parse_comp_filter(subel, cls.filter_subcomponent)
412 else:
413 raise AssertionError('unknown filter tag %r' % subel.tag)
414 return cls
546415
547416
548417 def calendar_from_resource(resource):
554423 return resource.file.calendar
555424
556425
557 def apply_filter(el, resource, tzify):
558 """Compile a filter element into a Python function.
559 """
560 if el is None:
561 # Empty filter, let's not bother parsing
562 return lambda x: True
563 c = calendar_from_resource(resource)
564 if c is None:
565 return False
566 return apply_comp_filter(list(el)[0], c, tzify)
567
568
569426 def extract_tzid(cal):
570427 return cal.subcomponents[0]['TZID']
571428
593450 @webdav.multistatus
594451 def report(self, environ, body, resources_by_hrefs, properties, base_href,
595452 base_resource, depth):
596 # TODO(jelmer): Verify that resource is an addressbook
453 # TODO(jelmer): Verify that resource is a calendar
597454 requested = None
598455 filter_el = None
599456 tztext = None
612469 else:
613470 tz = get_calendar_timezone(base_resource)
614471
615 def tzify(dt):
616 return as_tz_aware_ts(dt, tz)
472 def filter_fn(cls):
473 return parse_filter(filter_el, cls(tz))
474
475 def members(collection):
476 return itertools.chain(
477 collection.calendar_query(filter_fn),
478 collection.subcollections())
479
617480 for (href, resource) in webdav.traverse_resource(
618 base_resource, base_href, depth):
619 try:
620 filter_result = apply_filter(filter_el, resource, tzify)
621 except MissingProperty as e:
622 logging.warning(
623 'calendar_query: Ignoring calendar object %s, due '
624 'to missing property %s', href, e.property_name)
625 continue
626 if not filter_result:
627 continue
628 propstat = davcommon.get_properties_with_data(
629 self.data_property, href, resource, properties, environ,
630 requested)
631 yield webdav.Status(href, '200 OK', propstat=list(propstat))
481 base_resource, base_href, depth,
482 members=members):
483 # Ideally traverse_resource would only return the right things.
484 if getattr(resource, 'content_type', None) == 'text/calendar':
485 propstat = davcommon.get_properties_with_data(
486 self.data_property, href, resource, properties, environ,
487 requested)
488 yield webdav.Status(href, '200 OK', propstat=list(propstat))
632489
633490
634491 class CalendarColorProperty(webdav.Property):
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 if 'DTSTART' not in comp:
235 raise MissingProperty('DTSTART')
236
237 if not (end > tzify(comp['DTSTART'].dt)):
238 return False
239
240 if 'DTEND' in comp:
241 if tzify(comp['DTEND'].dt) < tzify(comp['DTSTART'].dt):
242 logging.debug('Invalid DTEND < DTSTART')
243 return (start < tzify(comp['DTEND'].dt))
244
245 if 'DURATION' in comp:
246 return (start < tzify(comp['DTSTART'].dt) + comp['DURATION'].dt)
247 if getattr(comp['DTSTART'].dt, 'time', None) is not None:
248 return (start <= tzify(comp['DTSTART'].dt))
249 else:
250 return (start < (tzify(comp['DTSTART'].dt) + datetime.timedelta(1)))
251
252
253 def apply_time_range_vjournal(start, end, comp, tzify):
254 if 'DTSTART' not in comp:
255 return False
256
257 if not (end > tzify(comp['DTSTART'].dt)):
258 return False
259
260 if getattr(comp['DTSTART'].dt, 'time', None) is not None:
261 return (start <= tzify(comp['DTSTART'].dt))
262 else:
263 return (start < (tzify(comp['DTSTART'].dt) + datetime.timedelta(1)))
264
265
266 def apply_time_range_vtodo(start, end, comp, tzify):
267 # See RFC4719, section 9.9
268 if 'DTSTART' in comp:
269 if 'DURATION' in comp and 'DUE' not in comp:
270 return (
271 start <= tzify(comp['DTSTART'].dt) + comp['DURATION'].dt and
272 (end > tzify(comp['DTSTART'].dt) or
273 end >= tzify(comp['DTSTART'].dt) + comp['DURATION'].dt)
274 )
275 elif 'DUE' in comp and 'DURATION' not in comp:
276 return (
277 (start <= tzify(comp['DTSTART'].dt) or
278 start < tzify(comp['DUE'].dt)) and
279 (end > tzify(comp['DTSTART'].dt) or
280 end < tzify(comp['DUE'].dt))
281 )
282 else:
283 return (start <= tzify(comp['DTSTART'].dt) and
284 end > tzify(comp['DTSTART'].dt))
285 elif 'DUE' in comp:
286 return start < tzify(comp['DUE'].dt) and end >= tzify(comp['DUE'].dt)
287 elif 'COMPLETED' in comp:
288 if 'CREATED' in comp:
289 return (
290 (start <= tzify(comp['CREATED'].dt) or
291 start <= tzify(comp['COMPLETED'].dt)) and
292 (end >= tzify(comp['CREATED'].dt) or
293 end >= tzify(comp['COMPLETED'].dt))
294 )
295 else:
296 return (
297 start <= tzify(comp['COMPLETED'].dt) and
298 end >= tzify(comp['COMPLETED'].dt)
299 )
300 elif 'CREATED' in comp:
301 return end >= tzify(comp['CREATED'].dt)
302 else:
303 return True
304
305
306 def apply_time_range_vfreebusy(start, end, comp, tzify):
307 if 'DTSTART' in comp and 'DTEND' in comp:
308 return (
309 start <= tzify(comp['DTEND'].dt) and
310 end > tzify(comp['DTEND'].dt)
311 )
312
313 for period in comp.get('FREEBUSY', []):
314 if start < period.end and end > period.start:
315 return True
316
317 return False
318
319
320 def apply_time_range_valarm(start, end, comp, tzify):
321 raise NotImplementedError(apply_time_range_valarm)
322
323
324 class PropertyTimeRangeMatcher(object):
325
326 def __init__(self, start, end):
327 self.start = start
328 self.end = end
329
330 def __repr__(self):
331 return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end)
332
333 def match(self, prop, tzify):
334 dt = tzify(prop.dt)
335 return (dt >= self.start and dt <= self.end)
336
337 def match_indexes(self, prop, tzify):
338 return any(self.match(vDDDTypes(vDatetime.from_ical(p)), tzify)
339 for p in prop[None])
340
341
342 class ComponentTimeRangeMatcher(object):
343
344 all_props = [
345 'DTSTART', 'DTEND', 'DURATION', 'CREATED', 'COMPLETED', 'DUE',
346 'FREEBUSY']
347
348 # According to https://tools.ietf.org/html/rfc4791, section 9.9 these
349 # are the properties to check.
350 component_handlers = {
351 'VEVENT': apply_time_range_vevent,
352 'VTODO': apply_time_range_vtodo,
353 'VJOURNAL': apply_time_range_vjournal,
354 'VFREEBUSY': apply_time_range_vfreebusy,
355 'VALARM': apply_time_range_valarm}
356
357 def __init__(self, start, end, comp=None):
358 self.start = start
359 self.end = end
360 self.comp = comp
361
362 def __repr__(self):
363 if self.comp is not None:
364 return "%s(%r, %r, comp=%r)" % (
365 self.__class__.__name__, self.start, self.end, self.comp)
366 else:
367 return "%s(%r, %r)" % (
368 self.__class__.__name__, self.start, self.end)
369
370 def match(self, comp, tzify):
371 try:
372 component_handler = self.component_handlers[comp.name]
373 except KeyError:
374 logging.warning('unknown component %r in time-range filter',
375 comp.name)
376 return False
377 return component_handler(self.start, self.end, comp, tzify)
378
379 def match_indexes(self, indexes, tzify):
380 vs = {}
381 for name, value in indexes.items():
382 if name and name[2:] in self.all_props:
383 if value:
384 if not isinstance(value[0], vDDDTypes):
385 vs[name[2:]] = vDDDTypes(vDatetime.from_ical(value[0]))
386 else:
387 vs[name[2:]] = value[0]
388
389 try:
390 component_handler = self.component_handlers[self.comp]
391 except KeyError:
392 logging.warning('unknown component %r in time-range filter',
393 self.comp)
394 return False
395 return component_handler(self.start, self.end, vs, tzify)
396
397 def index_keys(self):
398 if self.comp == 'VEVENT':
399 props = ['DTSTART', 'DTEND', 'DURATION']
400 elif self.comp == 'VTODO':
401 props = ['DTSTART', 'DUE', 'DURATION', 'CREATED', 'COMPLETED']
402 elif self.comp == 'VJOURNAL':
403 props = ['DTSTART']
404 elif self.comp == 'VFREEBUSY':
405 props = ['DTSTART', 'DTEND', 'FREEBUSY']
406 elif self.comp == 'VALARM':
407 raise NotImplementedError
408 else:
409 props = self.all_props
410 return [['P=' + prop] for prop in props]
411
412
413 class TextMatcher(object):
414
415 def __init__(self, text, collation=None, negate_condition=False):
416 if isinstance(text, str):
417 text = text.encode()
418 self.text = text
419 if collation is None:
420 collation = 'i;ascii-casemap'
421 self.collation = _mod_collation.get_collation(collation)
422 self.negate_condition = negate_condition
423
424 def __repr__(self):
425 return '%s(%r, collation=%r, negate_condition=%r)' % (
426 self.__class__.__name__, self.text, self.collation,
427 self.negate_condition)
428
429 def match_indexes(self, indexes):
430 return any(self.match(k) for k in indexes[None])
431
432 def match(self, prop):
433 if isinstance(prop, vText):
434 prop = prop.encode()
435 matches = self.collation(self.text, prop)
436 if self.negate_condition:
437 return not matches
438 else:
439 return matches
440
441
442 class ComponentFilter(object):
443
444 def __init__(self, name, children=None, is_not_defined=False,
445 time_range=None):
446 self.name = name
447 self.children = children
448 self.is_not_defined = is_not_defined
449 self.time_range = time_range
450 self.children = children or []
451
452 def __repr__(self):
453 return '%s(%r, children=%r, is_not_defined=%r, time_range=%r)' % (
454 self.__class__.__name__, self.name, self.children,
455 self.is_not_defined, self.time_range)
456
457 def filter_subcomponent(self, name, is_not_defined=False,
458 time_range=None):
459 ret = ComponentFilter(
460 name=name, is_not_defined=is_not_defined, time_range=time_range)
461 self.children.append(ret)
462 return ret
463
464 def filter_property(self, name, is_not_defined=False, time_range=None):
465 ret = PropertyFilter(
466 name=name, is_not_defined=is_not_defined, time_range=time_range)
467 self.children.append(ret)
468 return ret
469
470 def filter_time_range(self, start, end):
471 self.time_range = ComponentTimeRangeMatcher(
472 start, end, comp=self.name)
473 return self.time_range
474
475 def match(self, comp, tzify):
476 # From https://tools.ietf.org/html/rfc4791, 9.7.1:
477 # A CALDAV:comp-filter is said to match if:
478
479 # 2. The CALDAV:comp-filter XML element contains a
480 # CALDAV:is-not-defined XML element and the calendar object or calendar
481 # component type specified by the "name" attribute does not exist in
482 # the current scope;
483 if self.is_not_defined:
484 return comp.name != self.name
485
486 # 1: The CALDAV:comp-filter XML element is empty and the calendar
487 # object or calendar component type specified by the "name" attribute
488 # exists in the current scope;
489 if comp.name != self.name:
490 return False
491
492 # 3. The CALDAV:comp-filter XML element contains a CALDAV:time-range
493 # XML element and at least one recurrence instance in the targeted
494 # calendar component is scheduled to overlap the specified time range
495 if (self.time_range is not None and
496 not self.time_range.match(comp, tzify)):
497 return False
498
499 # ... and all specified CALDAV:prop-filter and CALDAV:comp-filter child
500 # XML elements also match the targeted calendar component;
501 for child in self.children:
502 if isinstance(child, ComponentFilter):
503 if not any(child.match(c, tzify) for c in comp.subcomponents):
504 return False
505 elif isinstance(child, PropertyFilter):
506 if not child.match(comp, tzify):
507 return False
508 else:
509 raise TypeError(child)
510
511 return True
512
513 def _implicitly_defined(self):
514 return any(not getattr(child, 'is_not_defined', False)
515 for child in self.children)
516
517 def match_indexes(self, indexes, tzify):
518 myindex = 'C=' + self.name
519 if self.is_not_defined:
520 return not bool(indexes[myindex])
521
522 subindexes = create_subindexes(indexes, myindex)
523
524 if (self.time_range is not None and
525 not self.time_range.match_indexes(subindexes, tzify)):
526 return False
527
528 for child in self.children:
529 if not child.match_indexes(subindexes, tzify):
530 return False
531
532 if not self._implicitly_defined():
533 return bool(indexes[myindex])
534
535 return True
536
537 def index_keys(self):
538 mine = 'C=' + self.name
539 for child in (
540 self.children +
541 ([self.time_range] if self.time_range else [])):
542 for tl in child.index_keys():
543 yield [(mine + '/' + child_index) for child_index in tl]
544 if not self._implicitly_defined():
545 yield [mine]
546
547
548 class PropertyFilter(object):
549
550 def __init__(self, name, children=None, is_not_defined=False,
551 time_range=None):
552 self.name = name
553 self.is_not_defined = is_not_defined
554 self.children = children or []
555 self.time_range = time_range
556
557 def __repr__(self):
558 return '%s(%r, children=%r, is_not_defined=%r, time_range=%r)' % (
559 self.__class__.__name__, self.name, self.children,
560 self.is_not_defined, self.time_range)
561
562 def filter_parameter(self, name, is_not_defined=False):
563 ret = ParameterFilter(name=name, is_not_defined=is_not_defined)
564 self.children.append(ret)
565 return ret
566
567 def filter_time_range(self, start, end):
568 self.time_range = PropertyTimeRangeMatcher(start, end)
569 return self.time_range
570
571 def filter_text_match(self, text, collation=None, negate_condition=False):
572 ret = TextMatcher(
573 text, collation=collation, negate_condition=negate_condition)
574 self.children.append(ret)
575 return ret
576
577 def match(self, comp, tzify):
578 # From https://tools.ietf.org/html/rfc4791, 9.7.2:
579 # A CALDAV:comp-filter is said to match if:
580
581 # The CALDAV:prop-filter XML element contains a CALDAV:is-not-defined
582 # XML element and no property of the type specified by the "name"
583 # attribute exists in the enclosing calendar component;
584
585 if self.is_not_defined:
586 return self.name not in comp
587
588 try:
589 prop = comp[self.name]
590 except KeyError:
591 return False
592
593 if self.time_range and not self.time_range.match(prop, tzify):
594 return False
595
596 for child in self.children:
597 if not child.match(prop):
598 return False
599
600 return True
601
602 def match_indexes(self, indexes, tzify):
603 myindex = 'P=' + self.name
604 if self.is_not_defined:
605 return not bool(indexes[myindex])
606 subindexes = create_subindexes(indexes, myindex)
607 if not self.children and not self.time_range:
608 return bool(indexes[myindex])
609
610 if (self.time_range is not None and
611 not self.time_range.match_indexes(subindexes, tzify)):
612 return False
613
614 for child in self.children:
615 if not child.match_indexes(subindexes):
616 return False
617
618 return True
619
620 def index_keys(self):
621 mine = 'P=' + self.name
622 for child in self.children:
623 if not isinstance(child, ParameterFilter):
624 continue
625 for tl in child.index_keys():
626 yield [(mine + '/' + child_index) for child_index in tl]
627 yield [mine]
628
629
630 class ParameterFilter(object):
631
632 def __init__(self, name, children=None, is_not_defined=False):
633 self.name = name
634 self.is_not_defined = is_not_defined
635 self.children = children or []
636
637 def filter_text_match(self, text, collation=None, negate_condition=False):
638 ret = TextMatcher(
639 text, collation=collation, negate_condition=negate_condition)
640 self.children.append(ret)
641 return ret
642
643 def match(self, prop):
644 if self.is_not_defined:
645 return self.name not in prop.params
646
647 try:
648 value = prop.params[self.name].encode()
649 except KeyError:
650 return False
651
652 for child in self.children:
653 if not child.match(value):
654 return False
655 return True
656
657 def index_keys(self):
658 yield ['A=' + self.name]
659
660 def match_indexes(self, indexes):
661 myindex = 'A=' + self.name
662 if self.is_not_defined:
663 return not bool(indexes[myindex])
664
665 try:
666 value = indexes[myindex][0]
667 except IndexError:
668 return False
669
670 for child in self.children:
671 if not child.match(value):
672 return False
673 return True
674
675
676 class CalendarFilter(Filter):
677 """A filter that works on ICalendar files."""
678
679 def __init__(self, default_timezone):
680 self.tzify = lambda dt: as_tz_aware_ts(dt, default_timezone)
681 self.children = []
682
683 def filter_subcomponent(self, name, is_not_defined=False,
684 time_range=None):
685 ret = ComponentFilter(
686 name=name, is_not_defined=is_not_defined, time_range=time_range)
687 self.children.append(ret)
688 return ret
689
690 def check(self, name, file):
691 if file.content_type != 'text/calendar':
692 return False
693 c = file.calendar
694 if c is None:
695 return False
696
697 for child_filter in self.children:
698 try:
699 if not child_filter.match(file.calendar, self.tzify):
700 return False
701 except MissingProperty as e:
702 logging.warning(
703 'calendar_query: Ignoring calendar object %s, due '
704 'to missing property %s', name, e.property_name)
705 return False
706 return True
707
708 def check_from_indexes(self, name, indexes):
709 for child_filter in self.children:
710 if not child_filter.match_indexes(
711 indexes, self.tzify):
712 return False
713 return True
714
715 def index_keys(self):
716 subindexes = []
717 for child in self.children:
718 subindexes.extend(child.index_keys())
719 return subindexes
720
721 def __repr__(self):
722 return '%s(%r)' % (self.__class__.__name__, self.children)
723
724
202725 class ICalendarFile(File):
203726 """Handle for ICalendar files."""
204727
271794 except KeyError:
272795 pass
273796 raise KeyError
797
798 def _get_index(self, key):
799 todo = [(self.calendar, key.split('/'))]
800 rest = []
801 while todo:
802 (c, segments) = todo.pop(0)
803 if segments and segments[0].startswith('C='):
804 if c.name == segments[0][2:]:
805 if len(segments) > 1 and segments[1].startswith('C='):
806 todo.extend(
807 (comp, segments[1:]) for comp in c.subcomponents)
808 else:
809 rest.append((c, segments[1:]))
810
811 for c, segments in rest:
812 if not segments:
813 yield True
814 elif segments[0].startswith('P='):
815 assert len(segments) == 1
816 try:
817 yield c[segments[0][2:]]
818 except KeyError:
819 pass
820 else:
821 raise AssertionError('segments: %r' % segments)
822
823
824 def as_tz_aware_ts(dt, default_timezone):
825 if not getattr(dt, 'time', None):
826 dt = datetime.datetime.combine(dt, datetime.time())
827 if dt.tzinfo is None:
828 dt = dt.replace(tzinfo=default_timezone)
829 assert dt.tzinfo
830 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
180237 :yield: (name, content_type, etag) tuples
181238 """
182239 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)
183292
184293 def get_file(self, name, content_type=None, etag=None):
185294 """Get the contents of an object.
4545 CollectionMetadata,
4646 FileBasedCollectionMetadata,
4747 )
48 from .index import MemoryIndex
4849
4950
5051 from dulwich.file import GitFile
193194
194195 def __init__(self, repo, ref=b'refs/heads/master',
195196 check_for_duplicate_uids=True):
196 super(GitStore, self).__init__()
197 super(GitStore, self).__init__(MemoryIndex())
197198 self.ref = ref
198199 self.repo = repo
199200 # Maps uids to (sha, fname)
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
4343 FileBasedCollectionMetadata,
4444 FILENAME as CONFIG_FILENAME,
4545 )
46 from .index import MemoryIndex
4647
4748
4849 DEFAULT_ENCODING = 'utf-8'
5657 """
5758
5859 def __init__(self, path, check_for_duplicate_uids=True):
59 super(VdirStore, self).__init__()
60 super(VdirStore, self).__init__(MemoryIndex())
6061 self.path = path
6162 self._check_for_duplicate_uids = check_for_duplicate_uids
6263 # Set of blob ids that have already been scanned
6768 cp.read([os.path.join(self.path, CONFIG_FILENAME)])
6869
6970 def save_config(cp, message):
70 with open(os.path.join(self.path, CONFIG_FILENAME), 'wb') as f:
71 with open(os.path.join(self.path, CONFIG_FILENAME), 'w') as f:
7172 cp.write(f)
7273 self.config = FileBasedCollectionMetadata(cp, save=save_config)
7374
239240
240241 :return: repository description as string
241242 """
242 raise NotImplementedError(self.get_description)
243 return self.config.get_description()
243244
244245 def set_description(self, description):
245246 """Set extended description.
246247
247248 :param description: repository description as string
248249 """
249 raise NotImplementedError(self.set_description)
250 self.config.set_description(description)
250251
251252 def set_comment(self, comment):
252253 """Set comment.
283284 :return: A Color code, or None
284285 """
285286 color = self._read_metadata('color')
286 assert color.startswith('#')
287 if color is not None:
288 assert color.startswith('#')
287289 return color
288290
289291 def set_color(self, color):
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
1616 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
1717 # MA 02110-1301, USA.
1818
19 from datetime import datetime
20 import unittest
21
22 from icalendar.cal import Event
23
2419 from wsgiref.util import setup_testing_defaults
2520
26 from xandikos import caldav, davcommon
21 from xandikos import caldav
2722 from xandikos.webdav import Property, WebDAVApp, ET
2823
2924 from xandikos.tests import test_webdav
7469 code, headers, contents = self.mkcalendar(app, '/resource/bla')
7570 self.assertEqual('201 Created', code)
7671 self.assertEqual(b'', contents)
77
78
79 class ApplyTextMatchTest(unittest.TestCase):
80
81 def test_default_collation(self):
82 el = ET.Element('someel')
83 el.text = b"foobar"
84 self.assertTrue(caldav.apply_text_match(el, b"FOOBAR"))
85 self.assertTrue(caldav.apply_text_match(el, b"foobar"))
86 self.assertFalse(caldav.apply_text_match(el, b"fobar"))
87
88 def test_casecmp_collation(self):
89 el = ET.Element('someel')
90 el.set('collation', 'i;ascii-casemap')
91 el.text = b"foobar"
92 self.assertTrue(caldav.apply_text_match(el, b"FOOBAR"))
93 self.assertTrue(caldav.apply_text_match(el, b"foobar"))
94 self.assertFalse(caldav.apply_text_match(el, b"fobar"))
95
96 def test_cmp_collation(self):
97 el = ET.Element('someel')
98 el.text = b"foobar"
99 el.set('collation', 'i;octet')
100 self.assertFalse(caldav.apply_text_match(el, b"FOOBAR"))
101 self.assertTrue(caldav.apply_text_match(el, b"foobar"))
102 self.assertFalse(caldav.apply_text_match(el, b"fobar"))
103
104 def test_unknown_collation(self):
105 el = ET.Element('someel')
106 el.set('collation', 'i;blah')
107 el.text = b"foobar"
108 self.assertRaises(davcommon.UnknownCollation,
109 caldav.apply_text_match, el, b"FOOBAR")
110
111
112 class ApplyTimeRangeVeventTests(unittest.TestCase):
113
114 def _tzify(self, dt):
115 return caldav.as_tz_aware_ts(dt, 'UTC')
116
117 def test_missing_dtstart(self):
118 ev = Event()
119 self.assertRaises(
120 caldav.MissingProperty, caldav.apply_time_range_vevent,
121 datetime.utcnow(), datetime.utcnow(), ev, self._tzify)
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.fromisoformat('2019-03-10 22:35:12')),
276 self._tzify(datetime.fromisoformat('2019-03-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.fromisoformat('2015-03-10 22:35:12')),
289 self._tzify(datetime.fromisoformat('2015-03-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.fromisoformat('2015-03-03 22:35:12')),
301 self._tzify(datetime.fromisoformat('2015-03-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.fromisoformat('2015-03-10 22:35:12')),
325 self._tzify(datetime.fromisoformat('2015-03-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 self.assertTrue(tm.match(b"FOOBAR"))
344 self.assertTrue(tm.match(b"foobar"))
345 self.assertFalse(tm.match(b"fobar"))
346
347 def test_casecmp_collation(self):
348 tm = TextMatcher(b'foobar', collation='i;ascii-casemap')
349 self.assertTrue(tm.match(b"FOOBAR"))
350 self.assertTrue(tm.match(b"foobar"))
351 self.assertFalse(tm.match(b"fobar"))
352
353 def test_cmp_collation(self):
354 tm = TextMatcher(b'foobar', 'i;octet')
355 self.assertFalse(tm.match(b"FOOBAR"))
356 self.assertTrue(tm.match(b"foobar"))
357 self.assertFalse(tm.match(b"fobar"))
358
359 def test_unknown_collation(self):
360 self.assertRaises(
361 _mod_collation.UnknownCollation, TextMatcher,
362 b'foobar', collation='i;blah')
363
364
365 class ApplyTimeRangeVeventTests(unittest.TestCase):
366
367 def _tzify(self, dt):
368 return as_tz_aware_ts(dt, 'UTC')
369
370 def test_missing_dtstart(self):
371 ev = Event()
372 self.assertRaises(
373 MissingProperty, apply_time_range_vevent,
374 datetime.utcnow(), datetime.utcnow(), ev, self._tzify)
2828
2929 from xandikos.icalendar import ICalendarFile
3030 from xandikos.store import (
31 DuplicateUidError, File, InvalidETag, NoSuchItem)
31 DuplicateUidError, File, InvalidETag, NoSuchItem, Filter)
3232 from xandikos.store.git import (
3333 GitStore, BareGitStore, TreeGitStore)
3434 from xandikos.store.vdir import (
119119 self.assertEqual([('foo.ics', 'text/calendar', etag)],
120120 list(gc.iter_with_etag()))
121121
122 def test_with_filter(self):
123 gc = self.create_store()
124 (name1, etag1) = gc.import_one('foo.ics', 'text/calendar',
125 [EXAMPLE_VCALENDAR1])
126 (name2, etag2) = gc.import_one('bar.ics', 'text/calendar',
127 [EXAMPLE_VCALENDAR2])
128
129 class DummyFilter(Filter):
130
131 def __init__(self, text):
132 self.text = text
133
134 def check(self, name, resource):
135 if resource.content_type != 'text/calendar':
136 return False
137 return self.text in b''.join(resource.content)
138
139 self.assertEqual(
140 2, len(list(gc.iter_with_filter(
141 filter=DummyFilter(b'do something')))))
142
143 [(ret_name, ret_file, ret_etag)] = list(gc.iter_with_filter(
144 filter=DummyFilter(b'do something else')))
145 self.assertEqual(ret_name, name2)
146 self.assertEqual(ret_etag, etag2)
147 self.assertEqual(ret_file.content_type, 'text/calendar')
148 self.assertEqual(
149 b''.join(ret_file.content),
150 EXAMPLE_VCALENDAR2.replace(b'\n', b'\r\n'))
151
152 def test_get_by_index(self):
153 gc = self.create_store()
154 (name1, etag1) = gc.import_one('foo.ics', 'text/calendar',
155 [EXAMPLE_VCALENDAR1])
156 (name2, etag2) = gc.import_one('bar.ics', 'text/calendar',
157 [EXAMPLE_VCALENDAR2])
158 self.assertEqual({}, dict(gc.index_manager.desired))
159
160 filtertext = 'C=VCALENDAR/C=VTODO/P=SUMMARY'
161
162 class DummyFilter(Filter):
163
164 def __init__(self, text):
165 self.text = text
166
167 def index_keys(self):
168 return [[filtertext]]
169
170 def check_from_indexes(self, name, index_values):
171 return any(self.text in v.encode()
172 for v in index_values[filtertext])
173
174 def check(self, name, resource):
175 if resource.content_type != 'text/calendar':
176 return False
177 return self.text in b''.join(resource.content)
178
179 self.assertEqual(
180 2, len(list(gc.iter_with_filter(
181 filter=DummyFilter(b'do something')))))
182
183 [(ret_name, ret_file, ret_etag)] = list(gc.iter_with_filter(
184 filter=DummyFilter(b'do something else')))
185 self.assertEqual(
186 {filtertext: 2},
187 dict(gc.index_manager.desired))
188
189 # Force index
190 gc.index.reset([filtertext])
191
192 [(ret_name, ret_file, ret_etag)] = list(gc.iter_with_filter(
193 filter=DummyFilter(b'do something else')))
194 self.assertEqual(
195 {filtertext: 2},
196 dict(gc.index_manager.desired))
197
198 self.assertEqual(ret_name, name2)
199 self.assertEqual(ret_etag, etag2)
200 self.assertEqual(ret_file.content_type, 'text/calendar')
201 self.assertEqual(
202 b''.join(ret_file.content),
203 EXAMPLE_VCALENDAR2.replace(b'\n', b'\r\n'))
204
122205 def test_import_one_duplicate_uid(self):
123206 gc = self.create_store()
124207 (name, etag) = gc.import_one('foo.ics', 'text/calendar',
1515 # along with this program; if not, write to the Free Software
1616 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
1717 # MA 02110-1301, USA.
18
19 """Tests for xandikos.web."""
20
21 import os
22 import shutil
23 import tempfile
24 import unittest
25
26 from .. import caldav
27 from ..icalendar import ICalendarFile
28 from ..store.vdir import VdirStore
29 from ..web import (
30 XandikosBackend,
31 CalendarCollection,
32 )
33
34
35 EXAMPLE_VCALENDAR1 = b"""\
36 BEGIN:VCALENDAR
37 VERSION:2.0
38 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN
39 BEGIN:VTODO
40 CREATED:20150314T223512Z
41 DTSTAMP:20150527T221952Z
42 LAST-MODIFIED:20150314T223512Z
43 STATUS:NEEDS-ACTION
44 SUMMARY:do something
45 UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff
46 END:VTODO
47 END:VCALENDAR
48 """
49
50
51 class CalendarCollectionTests(unittest.TestCase):
52
53 def setUp(self):
54 super(CalendarCollectionTests, self).setUp()
55 self.tempdir = tempfile.mkdtemp()
56 self.addCleanup(shutil.rmtree, self.tempdir)
57
58 self.store = VdirStore.create(os.path.join(self.tempdir, 'c'))
59 self.store.load_extra_file_handler(ICalendarFile)
60 self.backend = XandikosBackend(self.tempdir)
61
62 self.cal = CalendarCollection(self.backend, 'c', self.store)
63
64 def test_description(self):
65 self.store.set_description('foo')
66 self.assertEqual('foo', self.cal.get_calendar_description())
67
68 def test_color(self):
69 self.assertRaises(KeyError, self.cal.get_calendar_color)
70 self.cal.set_calendar_color('#aabbcc')
71 self.assertEqual('#aabbcc', self.cal.get_calendar_color())
72
73 def test_get_supported_calendar_components(self):
74 self.assertEqual(
75 ["VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY"],
76 self.cal.get_supported_calendar_components())
77
78 def test_calendar_query_vtodos(self):
79 def create_fn(cls):
80 f = cls(None)
81 f.filter_subcomponent('VCALENDAR').filter_subcomponent('VTODO')
82 return f
83 self.assertEqual([], list(self.cal.calendar_query(create_fn)))
84 self.store.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1])
85 result = list(self.cal.calendar_query(create_fn))
86 self.assertEqual(1, len(result))
87 self.assertEqual('foo.ics', result[0][0])
88 self.assertIs(self.store, result[0][1].store)
89 self.assertEqual('foo.ics', result[0][1].name)
90 self.assertEqual('text/calendar', result[0][1].content_type)
91
92 def test_calendar_query_vtodo_by_uid(self):
93 def create_fn(cls):
94 f = cls(None)
95 f.filter_subcomponent(
96 'VCALENDAR').filter_subcomponent(
97 'VTODO').filter_property(
98 'UID').filter_text_match(
99 b'bdc22720-b9e1-42c9-89c2-a85405d8fbff')
100 return f
101 self.assertEqual([], list(self.cal.calendar_query(create_fn)))
102 self.store.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1])
103 result = list(self.cal.calendar_query(create_fn))
104 self.assertEqual(1, len(result))
105 self.assertEqual('foo.ics', result[0][0])
106 self.assertIs(self.store, result[0][1].store)
107 self.assertEqual('foo.ics', result[0][1].name)
108 self.assertEqual('text/calendar', result[0][1].content_type)
109
110 def test_get_supported_calendar_data_types(self):
111 self.assertEqual(
112 [('text/calendar', '1.0'), ('text/calendar', '2.0')],
113 self.cal.get_supported_calendar_data_types())
114
115 def test_get_max_date_time(self):
116 self.assertEqual(
117 "99991231T235959Z", self.cal.get_max_date_time())
118
119 def test_get_min_date_time(self):
120 self.assertEqual(
121 "00010101T000000Z", self.cal.get_min_date_time())
122
123 def test_members(self):
124 self.assertEqual([], list(self.cal.members()))
125 self.store.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1])
126 result = list(self.cal.members())
127 self.assertEqual(1, len(result))
128 self.assertEqual('foo.ics', result[0][0])
129 self.assertIs(self.store, result[0][1].store)
130 self.assertEqual('foo.ics', result[0][1].name)
131 self.assertEqual('text/calendar', result[0][1].content_type)
132
133 def test_get_member(self):
134 self.assertRaises(KeyError, self.cal.get_member, 'foo.ics')
135 self.store.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1])
136 result = self.cal.get_member('foo.ics')
137 self.assertIs(self.store, result.store)
138 self.assertEqual('foo.ics', result.name)
139 self.assertEqual('text/calendar', result.content_type)
140
141 def test_delete_member(self):
142 self.assertRaises(KeyError, self.cal.get_member, 'foo.ics')
143 self.store.import_one('foo.ics', 'text/calendar', [EXAMPLE_VCALENDAR1])
144 self.cal.get_member('foo.ics')
145 self.cal.delete_member('foo.ics')
146 self.assertRaises(KeyError, self.cal.get_member, 'foo.ics')
147
148 def test_get_schedule_calendar_transparency(self):
149 self.assertEqual(
150 caldav.TRANSPARENCY_OPAQUE,
151 self.cal.get_schedule_calendar_transparency())
3636 from xandikos import __version__ as xandikos_version
3737 from xandikos import (access, apache, caldav, carddav, quota, sync, webdav,
3838 infit, scheduling, timezones)
39 from xandikos.icalendar import ICalendarFile
39 from xandikos.icalendar import (
40 ICalendarFile,
41 CalendarFilter,
42 )
4043 from xandikos.store import (
4144 DuplicateUidError,
4245 InvalidFileContents,
104107 class ObjectResource(webdav.Resource):
105108 """Object resource."""
106109
107 def __init__(self, store, name, content_type, etag):
110 def __init__(self, store, name, content_type, etag, file=None):
108111 self.store = store
109112 self.name = name
110113 self.etag = etag
111114 self.content_type = content_type
112 self._file = None
115 self._file = file
113116
114117 def __repr__(self):
115118 return "%s(%r, %r, %r, %r)" % (
224227 else:
225228 raise NotImplementedError(self.set_resource_types)
226229
227 def _get_resource(self, name, content_type, etag):
228 return ObjectResource(self.store, name, content_type, etag)
230 def _get_resource(self, name, content_type, etag, file=None):
231 return ObjectResource(self.store, name, content_type, etag, file=file)
229232
230233 def _get_subcollection(self, name):
231234 return self.backend.get_resource(posixpath.join(self.relpath, name))
249252 return create_strong_etag(self.store.get_ctag())
250253
251254 def members(self):
252 ret = []
253255 for (name, content_type, etag) in self.store.iter_with_etag():
254256 resource = self._get_resource(name, content_type, etag)
255 ret.append((name, resource))
257 yield (name, resource)
258 for (name, resource) in self.subcollections():
259 yield (name, resource)
260
261 def subcollections(self):
256262 for name in self.store.subdirectories():
257 ret.append((name, self._get_subcollection(name)))
258 return ret
263 yield (name, self._get_subcollection(name))
259264
260265 def get_member(self, name):
261266 assert name != ''
412417 self.store.config.set_order(order)
413418
414419 def get_calendar_timezone(self):
415 # TODO(jelmer): Read a magic file from the store?
420 # TODO(jelmer): Read from config
416421 raise KeyError
417422
418423 def set_calendar_timezone(self, content):
456461 def get_managed_attachments_server_url(self):
457462 # TODO(jelmer)
458463 raise KeyError
464
465 def calendar_query(self, create_filter_fn):
466 filter = create_filter_fn(CalendarFilter)
467 for (name, file, etag) in self.store.iter_with_filter(
468 filter=filter):
469 resource = self._get_resource(
470 name, file.content_type, etag, file=file)
471 yield (name, resource)
459472
460473
461474 class AddressbookCollection(StoreBasedCollection, carddav.Addressbook):
523536 return []
524537
525538 def members(self):
526 ret = []
527539 p = self.backend._map_to_file_path(self.relpath)
528540 for name in os.listdir(p):
529541 if name.startswith('.'):
530542 continue
531543 resource = self.get_member(name)
532 ret.append((name, resource))
533 return ret
544 yield (name, resource)
534545
535546 def get_member(self, name):
536547 assert name != ''
10131013 return href + '/'
10141014
10151015
1016 def traverse_resource(base_resource, base_href, depth):
1016 def traverse_resource(base_resource, base_href, depth, members=None):
10171017 """Traverse a resource.
10181018
10191019 :param base_resource: Resource to traverse from
10201020 :param base_href: href for base resource
10211021 :param depth: Depth ("0", "1", "infinity")
1022 :param members: Function to use to get members of each
1023 collection.
10221024 :return: Iterator over (URL, Resource) tuples
10231025 """
1026 if members is None:
1027 def members(c):
1028 return c.members()
10241029 todo = collections.deque([(base_href, base_resource, depth)])
10251030 while todo:
10261031 (href, resource, depth) = todo.popleft()
10401045 else:
10411046 raise AssertionError("invalid depth %r" % depth)
10421047 if COLLECTION_RESOURCE_TYPE in resource.resource_types:
1043 for (child_name, child_resource) in resource.members():
1048 for (child_name, child_resource) in members(resource):
10441049 child_href = urllib.parse.urljoin(href, child_name)
10451050 todo.append((child_href, child_resource, nextdepth))
10461051