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