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