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