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