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