1 #!/usr/bin/env python 2 3 """ 4 Interpretation of vCalendar content. 5 6 Copyright (C) 2014, 2015, 2016, 2017 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from bisect import bisect_left 23 from datetime import date, datetime, timedelta 24 from email.mime.text import MIMEText 25 from imiptools.dates import format_datetime, get_datetime, \ 26 get_datetime_item as get_item_from_datetime, \ 27 get_datetime_tzid, \ 28 get_duration, get_period, get_period_item, \ 29 get_recurrence_start_point, \ 30 get_time, get_timestamp, get_tzid, to_datetime, \ 31 to_timezone, to_utc_datetime 32 from imiptools.freebusy import FreeBusyPeriod 33 from imiptools.period import Period, RecurringPeriod 34 from vCalendar import iterwrite, parse, ParseError, to_dict, to_node 35 from vRecurrence import get_parameters, get_rule 36 import email.utils 37 38 try: 39 from cStringIO import StringIO 40 except ImportError: 41 from StringIO import StringIO 42 43 class Object: 44 45 "Access to calendar structures." 46 47 def __init__(self, fragment): 48 49 """ 50 Initialise the object with the given 'fragment'. This must be a 51 dictionary mapping an object type (such as "VEVENT") to a tuple 52 containing the object details and attributes, each being a dictionary 53 itself. 54 55 The result of parse_object can be processed to obtain a fragment by 56 obtaining a collection of records for an object type. For example: 57 58 l = parse_object(f, encoding, "VCALENDAR") 59 events = l["VEVENT"] 60 event = events[0] 61 62 Then, the specific object must be presented as follows: 63 64 object = Object({"VEVENT" : event}) 65 66 A convienience function is also provided to initialise objects: 67 68 object = new_object("VEVENT") 69 """ 70 71 self.objtype, (self.details, self.attr) = fragment.items()[0] 72 73 def get_uid(self): 74 return self.get_value("UID") 75 76 def get_recurrenceid(self): 77 78 """ 79 Return the recurrence identifier, normalised to a UTC datetime if 80 specified as a datetime or date with accompanying time zone information, 81 maintained as a date or floating datetime otherwise. If no recurrence 82 identifier is present, None is returned. 83 84 Note that this normalised form of the identifier may well not be the 85 same as the originally-specified identifier because that could have been 86 specified using an accompanying TZID attribute, whereas the normalised 87 form is effectively a converted datetime value. 88 """ 89 90 if not self.has_key("RECURRENCE-ID"): 91 return None 92 dt, attr = self.get_datetime_item("RECURRENCE-ID") 93 94 # Coerce any date to a UTC datetime if TZID was specified. 95 96 tzid = attr.get("TZID") 97 if tzid: 98 dt = to_timezone(to_datetime(dt, tzid), "UTC") 99 return format_datetime(dt) 100 101 def get_recurrence_start_point(self, recurrenceid, tzid): 102 103 """ 104 Return the start point corresponding to the given 'recurrenceid', using 105 the fallback 'tzid' to define the specific point in time referenced by 106 the recurrence identifier if the identifier has a date representation. 107 108 If 'recurrenceid' is given as None, this object's recurrence identifier 109 is used to obtain a start point, but if this object does not provide a 110 recurrence, None is returned. 111 112 A start point is typically used to match free/busy periods which are 113 themselves defined in terms of UTC datetimes. 114 """ 115 116 recurrenceid = recurrenceid or self.get_recurrenceid() 117 if recurrenceid: 118 return get_recurrence_start_point(recurrenceid, tzid) 119 else: 120 return None 121 122 def get_recurrence_start_points(self, recurrenceids, tzid): 123 return [self.get_recurrence_start_point(recurrenceid, tzid) for recurrenceid in recurrenceids] 124 125 # Structure access. 126 127 def add(self, obj): 128 129 "Add 'obj' to the structure." 130 131 name = obj.objtype 132 if not self.details.has_key(name): 133 l = self.details[name] = [] 134 else: 135 l = self.details[name] 136 l.append((obj.details, obj.attr)) 137 138 def copy(self): 139 return Object(self.to_dict()) 140 141 def get_items(self, name, all=True): 142 return get_items(self.details, name, all) 143 144 def get_item(self, name): 145 return get_item(self.details, name) 146 147 def get_value_map(self, name): 148 return get_value_map(self.details, name) 149 150 def get_values(self, name, all=True): 151 return get_values(self.details, name, all) 152 153 def get_value(self, name): 154 return get_value(self.details, name) 155 156 def get_utc_datetime(self, name, date_tzid=None): 157 return get_utc_datetime(self.details, name, date_tzid) 158 159 def get_date_value_items(self, name, tzid=None): 160 return get_date_value_items(self.details, name, tzid) 161 162 def get_date_value_item_periods(self, name, tzid=None): 163 return get_date_value_item_periods(self.details, name, self.get_main_period(tzid).get_duration(), tzid) 164 165 def get_period_values(self, name, tzid=None): 166 return get_period_values(self.details, name, tzid) 167 168 def get_datetime(self, name): 169 t = get_datetime_item(self.details, name) 170 if not t: return None 171 dt, attr = t 172 return dt 173 174 def get_datetime_item(self, name): 175 return get_datetime_item(self.details, name) 176 177 def get_duration(self, name): 178 return get_duration(self.get_value(name)) 179 180 # Serialisation. 181 182 def to_dict(self): 183 return to_dict(self.to_node()) 184 185 def to_node(self): 186 return to_node({self.objtype : [(self.details, self.attr)]}) 187 188 def to_part(self, method, encoding="utf-8", line_length=None): 189 return to_part(method, [self.to_node()], encoding, line_length) 190 191 def to_string(self, encoding="utf-8", line_length=None): 192 return to_string(self.to_node(), encoding, line_length) 193 194 # Direct access to the structure. 195 196 def has_key(self, name): 197 return self.details.has_key(name) 198 199 def get(self, name): 200 return self.details.get(name) 201 202 def keys(self): 203 return self.details.keys() 204 205 def __getitem__(self, name): 206 return self.details[name] 207 208 def __setitem__(self, name, value): 209 self.details[name] = value 210 211 def __delitem__(self, name): 212 del self.details[name] 213 214 def remove(self, name): 215 try: 216 del self[name] 217 except KeyError: 218 pass 219 220 def remove_all(self, names): 221 for name in names: 222 self.remove(name) 223 224 def preserve(self, names): 225 for name in self.keys(): 226 if not name in names: 227 self.remove(name) 228 229 # Computed results. 230 231 def get_main_period(self, tzid=None): 232 233 """ 234 Return a period object corresponding to the main start-end period for 235 the object. 236 """ 237 238 (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_main_period_items() 239 tzid = tzid or get_tzid(dtstart_attr, dtend_attr) 240 return RecurringPeriod(dtstart, dtend, tzid, "DTSTART", dtstart_attr, dtend_attr) 241 242 def get_main_period_items(self): 243 244 """ 245 Return two (value, attributes) items corresponding to the main start-end 246 period for the object. 247 """ 248 249 dtstart, dtstart_attr = self.get_datetime_item("DTSTART") 250 251 if self.has_key("DTEND"): 252 dtend, dtend_attr = self.get_datetime_item("DTEND") 253 elif self.has_key("DURATION"): 254 duration = self.get_duration("DURATION") 255 dtend = dtstart + duration 256 dtend_attr = dtstart_attr 257 else: 258 dtend, dtend_attr = dtstart, dtstart_attr 259 260 return (dtstart, dtstart_attr), (dtend, dtend_attr) 261 262 def get_periods(self, tzid, start=None, end=None, inclusive=False): 263 264 """ 265 Return periods defined by this object, employing the given 'tzid' where 266 no time zone information is defined, and limiting the collection to a 267 window of time with the given 'start' and 'end'. 268 269 If 'end' is omitted, only explicit recurrences and recurrences from 270 explicitly-terminated rules will be returned. 271 272 If 'inclusive' is set to a true value, any period occurring at the 'end' 273 will be included. 274 """ 275 276 return get_periods(self, tzid, start, end, inclusive) 277 278 def has_period(self, tzid, period): 279 280 """ 281 Return whether this object, employing the given 'tzid' where no time 282 zone information is defined, has the given 'period'. 283 """ 284 285 return period in self.get_periods(tzid, end=period.get_start_point(), inclusive=True) 286 287 def has_recurrence(self, tzid, recurrenceid): 288 289 """ 290 Return whether this object, employing the given 'tzid' where no time 291 zone information is defined, has the given 'recurrenceid'. 292 """ 293 294 start_point = self.get_recurrence_start_point(recurrenceid, tzid) 295 for p in self.get_periods(tzid, end=start_point, inclusive=True): 296 if p.get_start_point() == start_point: 297 return True 298 return False 299 300 def get_active_periods(self, recurrenceids, tzid, start=None, end=None): 301 302 """ 303 Return all periods specified by this object that are not replaced by 304 those defined by 'recurrenceids', using 'tzid' as a fallback time zone 305 to convert floating dates and datetimes, and using 'start' and 'end' to 306 respectively indicate the start and end of the time window within which 307 periods are considered. 308 """ 309 310 # Specific recurrences yield all specified periods. 311 312 periods = self.get_periods(tzid, start, end) 313 314 if self.get_recurrenceid(): 315 return periods 316 317 # Parent objects need to have their periods tested against redefined 318 # recurrences. 319 320 active = [] 321 322 for p in periods: 323 324 # Subtract any recurrences from the free/busy details of a 325 # parent object. 326 327 if not p.is_replaced(recurrenceids): 328 active.append(p) 329 330 return active 331 332 def get_freebusy_period(self, period, only_organiser=False): 333 334 """ 335 Return a free/busy period for the given 'period' provided by this 336 object, using the 'only_organiser' status to produce a suitable 337 transparency value. 338 """ 339 340 return FreeBusyPeriod( 341 period.get_start_point(), 342 period.get_end_point(), 343 self.get_value("UID"), 344 only_organiser and "ORG" or self.get_value("TRANSP") or "OPAQUE", 345 self.get_recurrenceid(), 346 self.get_value("SUMMARY"), 347 get_uri(self.get_value("ORGANIZER")) 348 ) 349 350 def get_participation_status(self, participant): 351 352 """ 353 Return the participation status of the given 'participant', with the 354 special value "ORG" indicating organiser-only participation. 355 """ 356 357 attendees = uri_dict(self.get_value_map("ATTENDEE")) 358 organiser = get_uri(self.get_value("ORGANIZER")) 359 360 attendee_attr = attendees.get(participant) 361 if attendee_attr: 362 return attendee_attr.get("PARTSTAT", "NEEDS-ACTION") 363 elif organiser == participant: 364 return "ORG" 365 366 return None 367 368 def get_participation(self, partstat, include_needs_action=False): 369 370 """ 371 Return whether 'partstat' indicates some kind of participation in an 372 event. If 'include_needs_action' is specified as a true value, events 373 not yet responded to will be treated as events with tentative 374 participation. 375 """ 376 377 return not partstat in ("DECLINED", "DELEGATED", "NEEDS-ACTION") or \ 378 include_needs_action and partstat == "NEEDS-ACTION" or \ 379 partstat == "ORG" 380 381 def get_tzid(self): 382 383 """ 384 Return a time zone identifier used by the start or end datetimes, 385 potentially suitable for converting dates to datetimes. 386 """ 387 388 if not self.has_key("DTSTART"): 389 return None 390 dtstart, dtstart_attr = self.get_datetime_item("DTSTART") 391 if self.has_key("DTEND"): 392 dtend, dtend_attr = self.get_datetime_item("DTEND") 393 else: 394 dtend_attr = None 395 return get_tzid(dtstart_attr, dtend_attr) 396 397 def is_shared(self): 398 399 """ 400 Return whether this object is shared based on the presence of a SEQUENCE 401 property. 402 """ 403 404 return self.get_value("SEQUENCE") is not None 405 406 def possibly_active_from(self, dt, tzid): 407 408 """ 409 Return whether the object is possibly active from or after the given 410 datetime 'dt' using 'tzid' to convert any dates or floating datetimes. 411 """ 412 413 dt = to_datetime(dt, tzid) 414 periods = self.get_periods(tzid) 415 416 for p in periods: 417 if p.get_end_point() > dt: 418 return True 419 420 return self.possibly_recurring_indefinitely() 421 422 def possibly_recurring_indefinitely(self): 423 424 "Return whether this object may recur indefinitely." 425 426 rrule = self.get_value("RRULE") 427 parameters = rrule and get_parameters(rrule) 428 until = parameters and parameters.get("UNTIL") 429 count = parameters and parameters.get("COUNT") 430 431 # Non-recurring periods or constrained recurrences. 432 433 if not rrule or until or count: 434 return False 435 436 # Unconstrained recurring periods will always lie beyond any specified 437 # datetime. 438 439 else: 440 return True 441 442 # Modification methods. 443 444 def set_datetime(self, name, dt, tzid=None): 445 446 """ 447 Set a datetime for property 'name' using 'dt' and the optional fallback 448 'tzid', returning whether an update has occurred. 449 """ 450 451 if dt: 452 old_value = self.get_value(name) 453 self[name] = [get_item_from_datetime(dt, tzid)] 454 return format_datetime(dt) != old_value 455 456 return False 457 458 def set_period(self, period): 459 460 "Set the given 'period' as the main start and end." 461 462 result = self.set_datetime("DTSTART", period.get_start()) 463 result = self.set_datetime("DTEND", period.get_end()) or result 464 if self.has_key("DURATION"): 465 del self["DURATION"] 466 467 return result 468 469 def set_periods(self, periods): 470 471 """ 472 Set the given 'periods' as recurrence date properties, replacing the 473 previous RDATE properties and ignoring any RRULE properties. 474 """ 475 476 old_values = set(self.get_date_value_item_periods("RDATE") or []) 477 new_rdates = [] 478 479 if self.has_key("RDATE"): 480 del self["RDATE"] 481 482 main_changed = False 483 484 for p in periods: 485 if p.origin == "RDATE" and p != self.get_main_period(): 486 new_rdates.append(get_period_item(p.get_start(), p.get_end())) 487 elif p.origin == "DTSTART": 488 main_changed = self.set_period(p) 489 490 if new_rdates: 491 self["RDATE"] = new_rdates 492 493 return main_changed or old_values != set(self.get_date_value_item_periods("RDATE") or []) 494 495 def set_rule(self, rule): 496 497 """ 498 Set the given 'rule' in this object, replacing the previous RRULE 499 property, returning whether the object has changed. The provided 'rule' 500 must be an item. 501 """ 502 503 if not rule: 504 return False 505 506 old_rrule = self.get_item("RRULE") 507 self["RRULE"] = [rule] 508 return old_rrule != rule 509 510 def set_exceptions(self, exceptions): 511 512 """ 513 Set the given 'exceptions' in this object, replacing the previous EXDATE 514 properties, returning whether the object has changed. The provided 515 'exceptions' must be a collection of items. 516 """ 517 518 old_exdates = set(self.get_date_value_item_periods("EXDATE") or []) 519 if exceptions: 520 self["EXDATE"] = exceptions 521 return old_exdates != set(self.get_date_value_item_periods("EXDATE") or []) 522 elif old_exdates: 523 del self["EXDATE"] 524 return True 525 else: 526 return False 527 528 def update_dtstamp(self): 529 530 "Update the DTSTAMP in the object." 531 532 dtstamp = self.get_utc_datetime("DTSTAMP") 533 utcnow = get_time() 534 dtstamp = format_datetime(dtstamp and dtstamp > utcnow and dtstamp or utcnow) 535 self["DTSTAMP"] = [(dtstamp, {})] 536 return dtstamp 537 538 def update_sequence(self, increment=False): 539 540 "Set or update the SEQUENCE in the object." 541 542 sequence = self.get_value("SEQUENCE") or "0" 543 self["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 544 return sequence 545 546 def update_exceptions(self, excluded, asserted): 547 548 """ 549 Update the exceptions to any rule by applying the list of 'excluded' 550 periods. Where 'asserted' periods are provided, exceptions will be 551 removed corresponding to those periods. 552 """ 553 554 old_exdates = self.get_date_value_item_periods("EXDATE") or [] 555 new_exdates = set(old_exdates) 556 new_exdates.update(excluded) 557 new_exdates.difference_update(asserted) 558 559 if not new_exdates and self.has_key("EXDATE"): 560 del self["EXDATE"] 561 else: 562 self["EXDATE"] = [] 563 for p in new_exdates: 564 self["EXDATE"].append(get_period_item(p.get_start(), p.get_end())) 565 566 return set(old_exdates) != new_exdates 567 568 def correct_object(self, tzid, permitted_values): 569 570 """ 571 Correct the object's period details using the given 'tzid' and 572 'permitted_values'. 573 """ 574 575 corrected = set() 576 rdates = [] 577 578 for period in self.get_periods(tzid): 579 corrected_period = period.get_corrected(permitted_values) 580 581 if corrected_period is period: 582 if period.origin == "RDATE": 583 rdates.append(period) 584 continue 585 586 if period.origin == "DTSTART": 587 self.set_period(corrected_period) 588 corrected.add("DTSTART") 589 elif period.origin == "RDATE": 590 rdates.append(corrected_period) 591 corrected.add("RDATE") 592 593 if "RDATE" in corrected: 594 self.set_periods(rdates) 595 596 return corrected 597 598 # Construction and serialisation. 599 600 def make_calendar(nodes, method=None): 601 602 """ 603 Return a complete calendar node wrapping the given 'nodes' and employing the 604 given 'method', if indicated. 605 """ 606 607 return ("VCALENDAR", {}, 608 (method and [("METHOD", {}, method)] or []) + 609 [("VERSION", {}, "2.0")] + 610 nodes 611 ) 612 613 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None, 614 attendee_attr=None, period=None): 615 616 """ 617 Return a calendar node defining the free/busy details described in the given 618 'freebusy' list, employing the given 'uid', for the given 'organiser' and 619 optional 'organiser_attr', with the optional 'attendee' providing recipient 620 details together with the optional 'attendee_attr'. 621 622 The result will be constrained to the 'period' if specified. 623 """ 624 625 record = [] 626 rwrite = record.append 627 628 rwrite(("ORGANIZER", organiser_attr or {}, organiser)) 629 630 if attendee: 631 rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 632 633 rwrite(("UID", {}, uid)) 634 635 if freebusy: 636 637 # Get a constrained view if start and end limits are specified. 638 639 if period: 640 periods = freebusy.get_overlapping([period]) 641 else: 642 periods = freebusy 643 644 # Write the limits of the resource. 645 646 if periods: 647 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start_point()))) 648 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end_point()))) 649 else: 650 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_start_point()))) 651 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_end_point()))) 652 653 for p in periods: 654 if p.transp == "OPAQUE": 655 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 656 map(format_datetime, [p.get_start_point(), p.get_end_point()]) 657 ))) 658 659 return ("VFREEBUSY", {}, record) 660 661 def parse_calendar(f, encoding): 662 663 """ 664 Parse the iTIP content from 'f' having the given 'encoding'. Return a 665 mapping from object types to collections of calendar objects. 666 """ 667 668 cal = parse_object(f, encoding, "VCALENDAR") 669 d = {} 670 671 for objtype, values in cal.items(): 672 d[objtype] = l = [] 673 for value in values: 674 l.append(Object({objtype : value})) 675 676 return d 677 678 def parse_object(f, encoding, objtype=None): 679 680 """ 681 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 682 given, only objects of that type will be returned. Otherwise, the root of 683 the content will be returned as a dictionary with a single key indicating 684 the object type. 685 686 Return None if the content was not readable or suitable. 687 """ 688 689 try: 690 try: 691 doctype, attrs, elements = obj = parse(f, encoding=encoding) 692 if objtype and doctype == objtype: 693 return to_dict(obj)[objtype][0] 694 elif not objtype: 695 return to_dict(obj) 696 finally: 697 f.close() 698 699 # NOTE: Handle parse errors properly. 700 701 except (ParseError, ValueError): 702 pass 703 704 return None 705 706 def parse_string(s, encoding, objtype=None): 707 708 """ 709 Parse the iTIP content from 's' having the given 'encoding'. If 'objtype' is 710 given, only objects of that type will be returned. Otherwise, the root of 711 the content will be returned as a dictionary with a single key indicating 712 the object type. 713 714 Return None if the content was not readable or suitable. 715 """ 716 717 return parse_object(StringIO(s), encoding, objtype) 718 719 def to_part(method, fragments, encoding="utf-8", line_length=None): 720 721 """ 722 Write using the given 'method', the given 'fragments' to a MIME 723 text/calendar part. 724 """ 725 726 out = StringIO() 727 try: 728 to_stream(out, make_calendar(fragments, method), encoding, line_length) 729 part = MIMEText(out.getvalue(), "calendar", encoding) 730 part.set_param("method", method) 731 return part 732 733 finally: 734 out.close() 735 736 def to_stream(out, fragment, encoding="utf-8", line_length=None): 737 738 "Write to the 'out' stream the given 'fragment'." 739 740 iterwrite(out, encoding=encoding, line_length=line_length).append(fragment) 741 742 def to_string(fragment, encoding="utf-8", line_length=None): 743 744 "Return a string encoding the given 'fragment'." 745 746 out = StringIO() 747 try: 748 to_stream(out, fragment, encoding, line_length) 749 return out.getvalue() 750 751 finally: 752 out.close() 753 754 def new_object(object_type): 755 756 "Make a new object of the given 'object_type'." 757 758 return Object({object_type : ({}, {})}) 759 760 def make_uid(user): 761 762 "Return a unique identifier for a new object by the given 'user'." 763 764 utcnow = get_timestamp() 765 return "imip-agent-%s-%s" % (utcnow, get_address(user)) 766 767 # Structure access functions. 768 769 def get_items(d, name, all=True): 770 771 """ 772 Get all items from 'd' for the given 'name', returning single items if 773 'all' is specified and set to a false value and if only one value is 774 present for the name. Return None if no items are found for the name or if 775 many items are found but 'all' is set to a false value. 776 """ 777 778 if d.has_key(name): 779 items = [(value or None, attr) for value, attr in d[name]] 780 if all: 781 return items 782 elif len(items) == 1: 783 return items[0] 784 else: 785 return None 786 else: 787 return None 788 789 def get_item(d, name): 790 return get_items(d, name, False) 791 792 def get_value_map(d, name): 793 794 """ 795 Return a dictionary for all items in 'd' having the given 'name'. The 796 dictionary will map values for the name to any attributes or qualifiers 797 that may have been present. 798 """ 799 800 items = get_items(d, name) 801 if items: 802 return dict(items) 803 else: 804 return {} 805 806 def values_from_items(items): 807 return map(lambda x: x[0], items) 808 809 def get_values(d, name, all=True): 810 if d.has_key(name): 811 items = d[name] 812 if not all and len(items) == 1: 813 return items[0][0] 814 else: 815 return values_from_items(items) 816 else: 817 return None 818 819 def get_value(d, name): 820 return get_values(d, name, False) 821 822 def get_date_value_items(d, name, tzid=None): 823 824 """ 825 Obtain items from 'd' having the given 'name', where a single item yields 826 potentially many values. Return a list of tuples of the form (value, 827 attributes) where the attributes have been given for the property in 'd'. 828 """ 829 830 items = get_items(d, name) 831 if items: 832 all_items = [] 833 for item in items: 834 values, attr = item 835 if not attr.has_key("TZID") and tzid: 836 attr["TZID"] = tzid 837 if not isinstance(values, list): 838 values = [values] 839 for value in values: 840 all_items.append((get_datetime(value, attr) or get_period(value, attr), attr)) 841 return all_items 842 else: 843 return None 844 845 def get_date_value_item_periods(d, name, duration, tzid=None): 846 847 """ 848 Obtain items from 'd' having the given 'name', where a single item yields 849 potentially many values. The 'duration' must be provided to define the 850 length of periods having only a start datetime. Return a list of periods 851 corresponding to the property in 'd'. 852 """ 853 854 items = get_date_value_items(d, name, tzid) 855 if not items: 856 return items 857 858 periods = [] 859 860 for value, attr in items: 861 if isinstance(value, tuple): 862 periods.append(RecurringPeriod(value[0], value[1], tzid, name, attr)) 863 else: 864 periods.append(RecurringPeriod(value, value + duration, tzid, name, attr)) 865 866 return periods 867 868 def get_period_values(d, name, tzid=None): 869 870 """ 871 Return period values from 'd' for the given property 'name', using 'tzid' 872 where specified to indicate the time zone. 873 """ 874 875 values = [] 876 for value, attr in get_items(d, name) or []: 877 if not attr.has_key("TZID") and tzid: 878 attr["TZID"] = tzid 879 start, end = get_period(value, attr) 880 values.append(Period(start, end, tzid=tzid)) 881 return values 882 883 def get_utc_datetime(d, name, date_tzid=None): 884 885 """ 886 Return the value provided by 'd' for 'name' as a datetime in the UTC zone 887 or as a date, converting any date to a datetime if 'date_tzid' is specified. 888 If no datetime or date is available, None is returned. 889 """ 890 891 t = get_datetime_item(d, name) 892 if not t: 893 return None 894 else: 895 dt, attr = t 896 return dt is not None and to_utc_datetime(dt, date_tzid) or None 897 898 def get_datetime_item(d, name): 899 900 """ 901 Return the value provided by 'd' for 'name' as a datetime or as a date, 902 together with the attributes describing it. Return None if no value exists 903 for 'name' in 'd'. 904 """ 905 906 t = get_item(d, name) 907 if not t: 908 return None 909 else: 910 value, attr = t 911 dt = get_datetime(value, attr) 912 tzid = get_datetime_tzid(dt) 913 if tzid: 914 attr["TZID"] = tzid 915 return dt, attr 916 917 # Conversion functions. 918 919 def get_address_parts(values): 920 921 "Return name and address tuples for each of the given 'values'." 922 923 l = [] 924 for name, address in values and email.utils.getaddresses(values) or []: 925 if is_mailto_uri(name): 926 name = name[7:] # strip "mailto:" 927 l.append((name, address)) 928 return l 929 930 def get_addresses(values): 931 932 """ 933 Return only addresses from the given 'values' which may be of the form 934 "Common Name <recipient@domain>", with the latter part being the address 935 itself. 936 """ 937 938 return [address for name, address in get_address_parts(values)] 939 940 def get_address(value): 941 942 "Return an e-mail address from the given 'value'." 943 944 if not value: return None 945 return get_addresses([value])[0] 946 947 def get_verbose_address(value, attr=None): 948 949 """ 950 Return a verbose e-mail address featuring any name from the given 'value' 951 and any accompanying 'attr' dictionary. 952 """ 953 954 l = get_address_parts([value]) 955 if not l: 956 return value 957 name, address = l[0] 958 if not name: 959 name = attr and attr.get("CN") 960 if name and address: 961 return "%s <%s>" % (name, address) 962 else: 963 return address 964 965 def is_mailto_uri(value): 966 967 """ 968 Return whether 'value' is a mailto: URI, with the protocol potentially being 969 in upper case. 970 """ 971 972 return value.lower().startswith("mailto:") 973 974 def get_uri(value): 975 976 "Return a URI for the given 'value'." 977 978 if not value: return None 979 980 # Normalise to "mailto:" or return other URI form. 981 982 return is_mailto_uri(value) and ("mailto:%s" % value[7:]) or \ 983 ":" in value and value or \ 984 "mailto:%s" % get_address(value) 985 986 def uri_parts(values): 987 988 "Return any common name plus the URI for each of the given 'values'." 989 990 return [(name, get_uri(address)) for name, address in get_address_parts(values)] 991 992 uri_value = get_uri 993 994 def uri_values(values): 995 return map(get_uri, values) 996 997 def uri_dict(d): 998 return dict([(get_uri(key), value) for key, value in d.items()]) 999 1000 def uri_item(item): 1001 return get_uri(item[0]), item[1] 1002 1003 def uri_items(items): 1004 return [(get_uri(value), attr) for value, attr in items] 1005 1006 # Operations on structure data. 1007 1008 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, ignore_dtstamp): 1009 1010 """ 1011 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 1012 'new_dtstamp', and the 'ignore_dtstamp' indication, whether the object 1013 providing the new information is really newer than the object providing the 1014 old information. 1015 """ 1016 1017 have_sequence = old_sequence is not None and new_sequence is not None 1018 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 1019 1020 have_dtstamp = old_dtstamp and new_dtstamp 1021 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 1022 1023 is_old_sequence = have_sequence and ( 1024 int(new_sequence) < int(old_sequence) or 1025 is_same_sequence and is_old_dtstamp 1026 ) 1027 1028 return is_same_sequence and ignore_dtstamp or not is_old_sequence 1029 1030 def check_delegation(attendee_map, attendee, attendee_attr): 1031 1032 """ 1033 Using the 'attendee_map', check the attributes for the given 'attendee' 1034 provided as 'attendee_attr', following the delegation chain back to the 1035 delegators and forward again to yield the delegate identities in each 1036 case. Pictorially... 1037 1038 attendee -> DELEGATED-FROM -> delegator 1039 ? <- DELEGATED-TO <--- 1040 1041 Return whether 'attendee' was identified as a delegate by providing the 1042 identity of any delegators referencing the attendee. 1043 """ 1044 1045 delegators = [] 1046 1047 # The recipient should have a reference to the delegator. 1048 1049 delegated_from = attendee_attr and attendee_attr.get("DELEGATED-FROM") 1050 if delegated_from: 1051 1052 # Examine all delegators. 1053 1054 for delegator in delegated_from: 1055 delegator_attr = attendee_map.get(delegator) 1056 1057 # The delegator should have a reference to the recipient. 1058 1059 delegated_to = delegator_attr and delegator_attr.get("DELEGATED-TO") 1060 if delegated_to and attendee in delegated_to: 1061 delegators.append(delegator) 1062 1063 return delegators 1064 1065 def get_periods(obj, tzid, start=None, end=None, inclusive=False): 1066 1067 """ 1068 Return periods for the given object 'obj', employing the given 'tzid' where 1069 no time zone information is available (for whole day events, for example), 1070 confining materialised periods to after the given 'start' datetime and 1071 before the given 'end' datetime. 1072 1073 If 'end' is omitted, only explicit recurrences and recurrences from 1074 explicitly-terminated rules will be returned. 1075 1076 If 'inclusive' is set to a true value, any period occurring at the 'end' 1077 will be included. 1078 """ 1079 1080 rrule = obj.get_value("RRULE") 1081 parameters = rrule and get_parameters(rrule) 1082 1083 # Use localised datetimes. 1084 1085 main_period = obj.get_main_period(tzid) 1086 1087 dtstart = main_period.get_start() 1088 dtstart_attr = main_period.get_start_attr() 1089 1090 # Attempt to get time zone details from the object, using the supplied zone 1091 # only as a fallback. 1092 1093 obj_tzid = obj.get_tzid() 1094 1095 if not rrule: 1096 periods = [main_period] 1097 1098 elif end or parameters and parameters.has_key("UNTIL") or parameters.has_key("COUNT"): 1099 1100 # Recurrence rules create multiple instances to be checked. 1101 # Conflicts may only be assessed within a period defined by policy 1102 # for the agent, with instances outside that period being considered 1103 # unchecked. 1104 1105 selector = get_rule(dtstart, rrule) 1106 periods = [] 1107 1108 until = parameters.get("UNTIL") 1109 if until: 1110 until_dt = to_timezone(get_datetime(until, dtstart_attr), obj_tzid) 1111 end = end and min(until_dt, end) or until_dt 1112 inclusive = True 1113 1114 # Define a selection period with a start point. The end will be handled 1115 # in the materialisation process below. 1116 1117 selection_period = Period(start, None) 1118 1119 # Obtain period instances, starting from the main period. Since counting 1120 # must start from the first period, filtering from a start date must be 1121 # done after the instances have been obtained. 1122 1123 for recurrence_start in selector.materialise(dtstart, end, parameters.get("COUNT"), parameters.get("BYSETPOS"), inclusive): 1124 1125 # Determine the resolution of the period. 1126 1127 create = len(recurrence_start) == 3 and date or datetime 1128 recurrence_start = to_timezone(create(*recurrence_start), obj_tzid) 1129 recurrence_end = recurrence_start + main_period.get_duration() 1130 1131 # Create the period with accompanying metadata based on the main 1132 # period and event details. 1133 1134 period = RecurringPeriod(recurrence_start, recurrence_end, tzid, "RRULE", dtstart_attr) 1135 1136 # Filter out periods before the start. 1137 1138 if period.within(selection_period): 1139 periods.append(period) 1140 1141 else: 1142 periods = [] 1143 1144 # Add recurrence dates. 1145 1146 rdates = obj.get_date_value_item_periods("RDATE", tzid) 1147 if rdates: 1148 periods += rdates 1149 1150 # Return a sorted list of the periods. 1151 1152 periods.sort() 1153 1154 # Exclude exception dates. 1155 1156 exdates = obj.get_date_value_item_periods("EXDATE", tzid) 1157 1158 if exdates: 1159 for period in exdates: 1160 i = bisect_left(periods, period) 1161 while i < len(periods) and periods[i] == period: 1162 del periods[i] 1163 1164 return periods 1165 1166 def get_main_period(periods): 1167 1168 "Return the main period from 'periods' using origin information." 1169 1170 for p in periods: 1171 if p.origin == "DTSTART": 1172 return p 1173 return None 1174 1175 def get_recurrence_periods(periods): 1176 1177 "Return recurrence periods from 'periods' using origin information." 1178 1179 l = [] 1180 for p in periods: 1181 if p.origin != "DTSTART": 1182 l.append(p) 1183 return l 1184 1185 def get_sender_identities(mapping): 1186 1187 """ 1188 Return a mapping from actual senders to the identities for which they 1189 have provided data, extracting this information from the given 1190 'mapping'. 1191 """ 1192 1193 senders = {} 1194 1195 for value, attr in mapping.items(): 1196 sent_by = attr.get("SENT-BY") 1197 if sent_by: 1198 sender = get_uri(sent_by) 1199 else: 1200 sender = value 1201 1202 if not senders.has_key(sender): 1203 senders[sender] = [] 1204 1205 senders[sender].append(value) 1206 1207 return senders 1208 1209 def get_window_end(tzid, days=100, start=None): 1210 1211 """ 1212 Return a datetime in the time zone indicated by 'tzid' marking the end of a 1213 window of the given number of 'days'. If 'start' is not indicated, the start 1214 of the window will be the current moment. 1215 """ 1216 1217 return to_timezone(start or datetime.now(), tzid) + timedelta(days) 1218 1219 # vim: tabstop=4 expandtab shiftwidth=4