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