1 #!/usr/bin/env python 2 3 """ 4 Interpretation of vCalendar content. 5 6 Copyright (C) 2014, 2015, 2016, 2017 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_timestamp, get_tzid, to_datetime, \ 31 to_timezone, to_utc_datetime 32 from imiptools.freebusy import FreeBusyPeriod 33 from imiptools.period import Period, RecurringPeriod 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, tzid=None): 48 49 """ 50 Initialise the object with the given 'fragment'. The optional 'tzid' 51 sets the fallback time zone used to convert datetimes without time zone 52 information. 53 54 The 'fragment' must be a dictionary mapping an object type (such as 55 "VEVENT") to a tuple containing the object details and attributes, 56 each being a dictionary itself. 57 58 The result of parse_object can be processed to obtain a fragment by 59 obtaining a collection of records for an object type. For example: 60 61 l = parse_object(f, encoding, "VCALENDAR") 62 events = l["VEVENT"] 63 event = events[0] 64 65 Then, the specific object must be presented as follows: 66 67 object = Object({"VEVENT" : event}) 68 69 A separately-stored, individual object can be obtained as follows: 70 71 object = Object(parse_object(f, encoding)) 72 73 A convienience function is also provided to initialise objects: 74 75 object = new_object("VEVENT") 76 """ 77 78 self.objtype, (self.details, self.attr) = fragment.items()[0] 79 self.set_tzid(tzid) 80 81 # Modify the object with separate recurrences. 82 83 self.modifying = [] 84 self.cancelling = [] 85 86 def set_tzid(self, tzid): 87 88 """ 89 Set the fallback 'tzid' for interpreting datetimes without time zone 90 information. 91 """ 92 93 self.tzid = tzid 94 95 def set_modifying(self, modifying): 96 97 """ 98 Set the 'modifying' objects affecting the periods provided by this 99 object. Such modifications can only be performed on a parent object, not 100 a specific recurrence object. 101 """ 102 103 if not self.get_recurrenceid(): 104 self.modifying = modifying 105 106 def set_cancelling(self, cancelling): 107 108 """ 109 Set the 'cancelling' objects affecting the periods provided by this 110 object. Such cancellations can only be performed on a parent object, not 111 a specific recurrence object. 112 """ 113 114 if not self.get_recurrenceid(): 115 self.cancelling = cancelling 116 117 # Basic object identification. 118 119 def get_uid(self): 120 121 "Return the universal identifier." 122 123 return self.get_value("UID") 124 125 def get_recurrenceid(self): 126 127 """ 128 Return the recurrence identifier, normalised to a UTC datetime if 129 specified as a datetime or date with accompanying time zone information, 130 maintained as a date or floating datetime otherwise. If no recurrence 131 identifier is present, None is returned. 132 133 Note that this normalised form of the identifier may well not be the 134 same as the originally-specified identifier because that could have been 135 specified using an accompanying TZID attribute, whereas the normalised 136 form is effectively a converted datetime value. 137 """ 138 139 if not self.has_key("RECURRENCE-ID"): 140 return None 141 142 dt, attr = self.get_datetime_item("RECURRENCE-ID") 143 144 # Coerce any date to a UTC datetime if TZID was specified. 145 146 tzid = attr.get("TZID") 147 if tzid: 148 dt = to_timezone(to_datetime(dt, tzid), "UTC") 149 150 return format_datetime(dt) 151 152 def get_recurrence_start_point(self, recurrenceid): 153 154 """ 155 Return the start point corresponding to the given 'recurrenceid', using 156 the fallback time zone to define the specific point in time referenced 157 by the recurrence identifier if the identifier has a date 158 representation. 159 160 If 'recurrenceid' is given as None, this object's recurrence identifier 161 is used to obtain a start point, but if this object does not provide a 162 recurrence, None is returned. 163 164 A start point is typically used to match free/busy periods which are 165 themselves defined in terms of UTC datetimes. 166 """ 167 168 recurrenceid = recurrenceid or self.get_recurrenceid() 169 if recurrenceid: 170 return get_recurrence_start_point(recurrenceid, self.tzid) 171 else: 172 return None 173 174 def get_recurrence_start_points(self, recurrenceids): 175 176 """ 177 Return start points for 'recurrenceids' using the fallback time zone for 178 identifiers with date representations. 179 """ 180 181 return map(self.get_recurrence_start_point, recurrenceids) 182 183 # Structure access. 184 185 def add(self, obj): 186 187 "Add 'obj' to the structure." 188 189 name = obj.objtype 190 if not self.details.has_key(name): 191 l = self.details[name] = [] 192 else: 193 l = self.details[name] 194 l.append((obj.details, obj.attr)) 195 196 def copy(self): 197 return Object(self.to_dict(), self.tzid) 198 199 # Access to (value, attributes) items. 200 201 def get_items(self, name, all=True): 202 return get_items(self.details, name, all) 203 204 def get_item(self, name): 205 return get_item(self.details, name) 206 207 # Access to mappings. 208 209 def get_value_map(self, name): 210 return get_value_map(self.details, name) 211 212 # Access to mapped values. 213 214 def get_values(self, name, all=True): 215 return get_values(self.details, name, all) 216 217 def get_value(self, name): 218 return get_value(self.details, name) 219 220 def set_value(self, name, value, attr=None): 221 self.details[name] = [(value, attr or {})] 222 223 # Convenience methods asserting URI values. 224 225 def get_uri_items(self, name, all=True): 226 return uri_items(self.get_items(name, all)) 227 228 def get_uri_item(self, name): 229 return uri_item(self.get_item(name)) 230 231 def get_uri_map(self, name): 232 return uri_dict(self.get_value_map(name)) 233 234 def get_uri_values(self, name): 235 return uri_values(self.get_values(name)) 236 237 def get_uri_value(self, name): 238 return uri_value(self.get_value(name)) 239 240 get_uri = get_uri_value 241 242 # Access to details as temporal objects. 243 244 def get_utc_datetime(self, name): 245 return get_utc_datetime(self.details, name, self.tzid) 246 247 def get_date_value_items(self, name): 248 return get_date_value_items(self.details, name, self.tzid) 249 250 def get_date_value_item_periods(self, name): 251 return get_date_value_item_periods(self.details, name, 252 self.get_main_period().get_duration(), self.tzid) 253 254 def get_period_values(self, name): 255 return get_period_values(self.details, name, self.tzid) 256 257 def get_datetime(self, name): 258 t = get_datetime_item(self.details, name) 259 if not t: return None 260 dt, attr = t 261 return dt 262 263 def get_datetime_item(self, name): 264 return get_datetime_item(self.details, name) 265 266 def get_duration(self, name): 267 return get_duration(self.get_value(name)) 268 269 # Serialisation. 270 271 def to_dict(self): 272 return to_dict(self.to_node()) 273 274 def to_node(self): 275 return to_node({self.objtype : [(self.details, self.attr)]}) 276 277 def to_part(self, method, encoding="utf-8", line_length=None): 278 return to_part(method, [self.to_node()], encoding, line_length) 279 280 def to_string(self, encoding="utf-8", line_length=None): 281 return to_string(self.to_node(), encoding, line_length) 282 283 # Direct access to the structure. 284 285 def has_key(self, name): 286 return self.details.has_key(name) 287 288 def get(self, name): 289 return self.details.get(name) 290 291 def keys(self): 292 return self.details.keys() 293 294 def __getitem__(self, name): 295 return self.details[name] 296 297 def __setitem__(self, name, value): 298 self.details[name] = value 299 300 def __delitem__(self, name): 301 del self.details[name] 302 303 def remove(self, name): 304 try: 305 del self[name] 306 except KeyError: 307 pass 308 309 def remove_all(self, names): 310 for name in names: 311 self.remove(name) 312 313 def preserve(self, names): 314 for name in self.keys(): 315 if not name in names: 316 self.remove(name) 317 318 # Computed results. 319 320 def get_main_period(self): 321 322 """ 323 Return a period object corresponding to the main start-end period for 324 the object. 325 """ 326 327 (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_main_period_items() 328 tzid = get_tzid(dtstart_attr, dtend_attr) or self.tzid 329 return RecurringPeriod(dtstart, dtend, tzid, "DTSTART", dtstart_attr, dtend_attr) 330 331 def get_main_period_items(self): 332 333 """ 334 Return two (value, attributes) items corresponding to the main start-end 335 period for the object. 336 """ 337 338 dtstart, dtstart_attr = self.get_datetime_item("DTSTART") 339 340 if self.has_key("DTEND"): 341 dtend, dtend_attr = self.get_datetime_item("DTEND") 342 elif self.has_key("DURATION"): 343 duration = self.get_duration("DURATION") 344 dtend = dtstart + duration 345 dtend_attr = dtstart_attr 346 else: 347 dtend, dtend_attr = dtstart, dtstart_attr 348 349 return (dtstart, dtstart_attr), (dtend, dtend_attr) 350 351 def get_periods(self, start=None, end=None, inclusive=False): 352 353 """ 354 Return periods defined by this object, employing the fallback time zone 355 where no time zone information is defined, and limiting the collection 356 to a window of time with the given 'start' and 'end'. 357 358 If 'end' is omitted, only explicit recurrences and recurrences from 359 explicitly-terminated rules will be returned. 360 361 If 'inclusive' is set to a true value, any period occurring at the 'end' 362 will be included. 363 """ 364 365 return get_periods(self, start, end, inclusive) 366 367 def has_period(self, period): 368 369 """ 370 Return whether this object, employing the fallback time zone where no 371 time zone information is defined, has the given 'period'. 372 """ 373 374 return period in self.get_periods(end=period.get_start_point(), inclusive=True) 375 376 def has_recurrence(self, recurrenceid): 377 378 """ 379 Return whether this object, employing the fallback time zone where no 380 time zone information is defined, has the given 'recurrenceid'. 381 """ 382 383 start_point = self.get_recurrence_start_point(recurrenceid) 384 385 for p in self.get_periods(end=start_point, inclusive=True): 386 if p.get_start_point() == start_point: 387 return True 388 389 return False 390 391 def get_updated_periods(self, start=None, end=None): 392 393 """ 394 Return pairs of periods specified by this object and any modifying or 395 cancelling objects, providing correspondences between the original 396 period definitions and those applying after modifications and 397 cancellations have been considered. 398 399 The fallback time zone is used to convert floating dates and datetimes, 400 and 'start' and 'end' respectively indicate the start and end of any 401 time window within which periods are considered. 402 """ 403 404 # Specific recurrences yield all specified periods. 405 406 original = self.get_periods(start, end) 407 408 if self.get_recurrenceid(): 409 updated = [] 410 for p in original: 411 updated.append((p, p)) 412 return updated 413 414 # Parent objects need to have their periods tested against redefined 415 # recurrences. 416 417 modified = {} 418 419 for obj in self.modifying: 420 periods = obj.get_periods(start, end) 421 if periods: 422 modified[obj.get_recurrenceid()] = periods[0] 423 424 cancelled = set() 425 426 for obj in self.cancelling: 427 cancelled.add(obj.get_recurrenceid()) 428 429 updated = [] 430 431 for p in original: 432 recurrenceid = p.is_replaced(modified.keys()) 433 434 # Produce an original-to-modified correspondence, setting the origin 435 # to distinguish the period from the main period. 436 437 if recurrenceid: 438 mp = modified.get(recurrenceid) 439 if mp.origin == "DTSTART" and p.origin != "DTSTART": 440 mp.origin = "DTSTART-RECUR" 441 updated.append((p, mp)) 442 continue 443 444 # Produce an original-to-null correspondence where cancellation has 445 # occurred. 446 447 recurrenceid = p.is_replaced(cancelled) 448 449 if recurrenceid: 450 updated.append((p, None)) 451 continue 452 453 # Produce an identity correspondence where no modification or 454 # cancellation has occurred. 455 456 updated.append((p, p)) 457 458 return updated 459 460 def get_active_periods(self, start=None, end=None): 461 462 """ 463 Return all periods specified by this object that are not replaced by 464 those defined by modifying or cancelling objects, using the fallback 465 time zone to convert floating dates and datetimes, and using 'start' and 466 'end' to respectively indicate the start and end of the time window 467 within which periods are considered. 468 """ 469 470 active = [] 471 472 for old, new in self.get_updated_periods(start, end): 473 if new: 474 active.append(new) 475 476 return active 477 478 def get_freebusy_period(self, period, only_organiser=False): 479 480 """ 481 Return a free/busy period for the given 'period' provided by this 482 object, using the 'only_organiser' status to produce a suitable 483 transparency value. 484 """ 485 486 return FreeBusyPeriod( 487 period.get_start_point(), 488 period.get_end_point(), 489 self.get_value("UID"), 490 only_organiser and "ORG" or self.get_value("TRANSP") or "OPAQUE", 491 self.get_recurrenceid(), 492 self.get_value("SUMMARY"), 493 self.get_uri("ORGANIZER") 494 ) 495 496 def get_participation_status(self, participant): 497 498 """ 499 Return the participation status of the given 'participant', with the 500 special value "ORG" indicating organiser-only participation. 501 """ 502 503 attendees = self.get_uri_map("ATTENDEE") 504 organiser = self.get_uri("ORGANIZER") 505 506 attendee_attr = attendees.get(participant) 507 if attendee_attr: 508 return attendee_attr.get("PARTSTAT", "NEEDS-ACTION") 509 elif organiser == participant: 510 return "ORG" 511 512 return None 513 514 def get_participation(self, partstat, include_needs_action=False): 515 516 """ 517 Return whether 'partstat' indicates some kind of participation in an 518 event. If 'include_needs_action' is specified as a true value, events 519 not yet responded to will be treated as events with tentative 520 participation. 521 """ 522 523 return not partstat in ("DECLINED", "DELEGATED", "NEEDS-ACTION") or \ 524 include_needs_action and partstat == "NEEDS-ACTION" or \ 525 partstat == "ORG" 526 527 def get_tzid(self): 528 529 """ 530 Return a time zone identifier used by the start or end datetimes, 531 potentially suitable for converting dates to datetimes. Where no 532 identifier is associated with the datetimes, provide any fallback time 533 zone identifier. 534 """ 535 536 (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_main_period_items() 537 return get_tzid(dtstart_attr, dtend_attr) or self.tzid 538 539 def is_shared(self): 540 541 """ 542 Return whether this object is shared based on the presence of a SEQUENCE 543 property. 544 """ 545 546 return self.get_value("SEQUENCE") is not None 547 548 def possibly_active_from(self, dt): 549 550 """ 551 Return whether the object is possibly active from or after the given 552 datetime 'dt' using the fallback time zone to convert any dates or 553 floating datetimes. 554 """ 555 556 dt = to_datetime(dt, self.tzid) 557 periods = self.get_periods() 558 559 for p in periods: 560 if p.get_end_point() > dt: 561 return True 562 563 return self.possibly_recurring_indefinitely() 564 565 def possibly_recurring_indefinitely(self): 566 567 "Return whether this object may recur indefinitely." 568 569 rrule = self.get_value("RRULE") 570 parameters = rrule and get_parameters(rrule) 571 until = parameters and parameters.get("UNTIL") 572 count = parameters and parameters.get("COUNT") 573 574 # Non-recurring periods or constrained recurrences. 575 576 if not rrule or until or count: 577 return False 578 579 # Unconstrained recurring periods will always lie beyond any specified 580 # datetime. 581 582 else: 583 return True 584 585 # Modification methods. 586 587 def set_datetime(self, name, dt): 588 589 """ 590 Set a datetime for property 'name' using 'dt' and the fallback time zone 591 where necessary, returning whether an update has occurred. 592 """ 593 594 if dt: 595 old_value = self.get_value(name) 596 self[name] = [get_item_from_datetime(dt, self.tzid)] 597 return format_datetime(dt) != old_value 598 599 return False 600 601 def set_period(self, period): 602 603 "Set the given 'period' as the main start and end." 604 605 result = self.set_datetime("DTSTART", period.get_start()) 606 result = self.set_datetime("DTEND", period.get_end()) or result 607 if self.has_key("DURATION"): 608 del self["DURATION"] 609 610 return result 611 612 def set_periods(self, periods): 613 614 """ 615 Set the given 'periods' as recurrence date properties, replacing the 616 previous RDATE properties and ignoring any RRULE properties. 617 """ 618 619 old_values = set(self.get_date_value_item_periods("RDATE") or []) 620 new_rdates = [] 621 622 if self.has_key("RDATE"): 623 del self["RDATE"] 624 625 main_changed = False 626 627 for p in periods: 628 if p.origin == "DTSTART": 629 main_changed = self.set_period(p) 630 elif p.origin != "RRULE" and p != self.get_main_period(): 631 new_rdates.append(get_period_item(p.get_start(), p.get_end())) 632 633 if new_rdates: 634 self["RDATE"] = new_rdates 635 636 return main_changed or old_values != set(self.get_date_value_item_periods("RDATE") or []) 637 638 def set_rule(self, rule): 639 640 """ 641 Set the given 'rule' in this object, replacing the previous RRULE 642 property, returning whether the object has changed. The provided 'rule' 643 must be an item. 644 """ 645 646 if not rule: 647 return False 648 649 old_rrule = self.get_item("RRULE") 650 self["RRULE"] = [rule] 651 return old_rrule != rule 652 653 def set_exceptions(self, exceptions): 654 655 """ 656 Set the given 'exceptions' in this object, replacing the previous EXDATE 657 properties, returning whether the object has changed. The provided 658 'exceptions' must be a collection of items. 659 """ 660 661 old_exdates = set(self.get_date_value_item_periods("EXDATE") or []) 662 if exceptions: 663 self["EXDATE"] = exceptions 664 return old_exdates != set(self.get_date_value_item_periods("EXDATE") or []) 665 elif old_exdates: 666 del self["EXDATE"] 667 return True 668 else: 669 return False 670 671 def update_dtstamp(self): 672 673 "Update the DTSTAMP in the object." 674 675 dtstamp = self.get_utc_datetime("DTSTAMP") 676 utcnow = get_time() 677 dtstamp = format_datetime(dtstamp and dtstamp > utcnow and dtstamp or utcnow) 678 self["DTSTAMP"] = [(dtstamp, {})] 679 return dtstamp 680 681 def update_sequence(self, increment=False): 682 683 "Set or update the SEQUENCE in the object." 684 685 sequence = self.get_value("SEQUENCE") or "0" 686 self["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 687 return sequence 688 689 def update_exceptions(self, excluded, asserted): 690 691 """ 692 Update the exceptions to any rule by applying the list of 'excluded' 693 periods. Where 'asserted' periods are provided, exceptions will be 694 removed corresponding to those periods. 695 """ 696 697 old_exdates = self.get_date_value_item_periods("EXDATE") or [] 698 new_exdates = set(old_exdates) 699 new_exdates.update(excluded) 700 new_exdates.difference_update(asserted) 701 702 if not new_exdates and self.has_key("EXDATE"): 703 del self["EXDATE"] 704 else: 705 self["EXDATE"] = [] 706 for p in new_exdates: 707 self["EXDATE"].append(get_period_item(p.get_start(), p.get_end())) 708 709 return set(old_exdates) != new_exdates 710 711 def correct_object(self, permitted_values): 712 713 "Correct the object's period details using the 'permitted_values'." 714 715 corrected = set() 716 rdates = [] 717 718 for period in self.get_periods(): 719 corrected_period = period.get_corrected(permitted_values) 720 721 if corrected_period is period: 722 if period.origin == "RDATE": 723 rdates.append(period) 724 continue 725 726 if period.origin == "DTSTART": 727 self.set_period(corrected_period) 728 corrected.add("DTSTART") 729 elif period.origin == "RDATE": 730 rdates.append(corrected_period) 731 corrected.add("RDATE") 732 733 if "RDATE" in corrected: 734 self.set_periods(rdates) 735 736 return corrected 737 738 # Construction and serialisation. 739 740 def make_calendar(nodes, method=None): 741 742 """ 743 Return a complete calendar node wrapping the given 'nodes' and employing the 744 given 'method', if indicated. 745 """ 746 747 return ("VCALENDAR", {}, 748 (method and [("METHOD", {}, method)] or []) + 749 [("VERSION", {}, "2.0")] + 750 nodes 751 ) 752 753 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None, 754 attendee_attr=None, period=None): 755 756 """ 757 Return a calendar node defining the free/busy details described in the given 758 'freebusy' list, employing the given 'uid', for the given 'organiser' and 759 optional 'organiser_attr', with the optional 'attendee' providing recipient 760 details together with the optional 'attendee_attr'. 761 762 The result will be constrained to the 'period' if specified. 763 """ 764 765 record = [] 766 rwrite = record.append 767 768 rwrite(("ORGANIZER", organiser_attr or {}, organiser)) 769 770 if attendee: 771 rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 772 773 rwrite(("UID", {}, uid)) 774 775 if freebusy: 776 777 # Get a constrained view if start and end limits are specified. 778 779 if period: 780 periods = freebusy.get_overlapping([period]) 781 else: 782 periods = freebusy 783 784 # Write the limits of the resource. 785 786 if periods: 787 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start_point()))) 788 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end_point()))) 789 else: 790 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_start_point()))) 791 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_end_point()))) 792 793 for p in periods: 794 if p.transp == "OPAQUE": 795 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 796 map(format_datetime, [p.get_start_point(), p.get_end_point()]) 797 ))) 798 799 return ("VFREEBUSY", {}, record) 800 801 def parse_calendar(f, encoding, tzid=None): 802 803 """ 804 Parse the iTIP content from 'f' having the given 'encoding'. Return a 805 mapping from object types to collections of calendar objects. If 'tzid' is 806 specified, use it to set the fallback time zone on all returned objects. 807 """ 808 809 cal = parse_object(f, encoding, "VCALENDAR") 810 d = {} 811 812 for objtype, values in cal.items(): 813 d[objtype] = l = [] 814 for value in values: 815 l.append(Object({objtype : value}, tzid)) 816 817 return d 818 819 def parse_object(f, encoding, objtype=None): 820 821 """ 822 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 823 given, only objects of that type will be returned. Otherwise, the root of 824 the content will be returned as a dictionary with a single key indicating 825 the object type. 826 827 Return None if the content was not readable or suitable. 828 """ 829 830 try: 831 try: 832 doctype, attrs, elements = obj = parse(f, encoding=encoding) 833 if objtype and doctype == objtype: 834 return to_dict(obj)[objtype][0] 835 elif not objtype: 836 return to_dict(obj) 837 finally: 838 f.close() 839 840 # NOTE: Handle parse errors properly. 841 842 except (ParseError, ValueError): 843 pass 844 845 return None 846 847 def parse_string(s, encoding, objtype=None): 848 849 """ 850 Parse the iTIP content from 's' having the given 'encoding'. If 'objtype' is 851 given, only objects of that type will be returned. Otherwise, the root of 852 the content will be returned as a dictionary with a single key indicating 853 the object type. 854 855 Return None if the content was not readable or suitable. 856 """ 857 858 return parse_object(StringIO(s), encoding, objtype) 859 860 def to_part(method, fragments, encoding="utf-8", line_length=None): 861 862 """ 863 Write using the given 'method', the given 'fragments' to a MIME 864 text/calendar part. 865 """ 866 867 out = StringIO() 868 try: 869 to_stream(out, make_calendar(fragments, method), encoding, line_length) 870 part = MIMEText(out.getvalue(), "calendar", encoding) 871 part.set_param("method", method) 872 return part 873 874 finally: 875 out.close() 876 877 def to_stream(out, fragment, encoding="utf-8", line_length=None): 878 879 "Write to the 'out' stream the given 'fragment'." 880 881 iterwrite(out, encoding=encoding, line_length=line_length).append(fragment) 882 883 def to_string(fragment, encoding="utf-8", line_length=None): 884 885 "Return a string encoding the given 'fragment'." 886 887 out = StringIO() 888 try: 889 to_stream(out, fragment, encoding, line_length) 890 return out.getvalue() 891 892 finally: 893 out.close() 894 895 def new_object(object_type, organiser=None, organiser_attr=None, tzid=None): 896 897 """ 898 Make a new object of the given 'object_type' and optional 'organiser', 899 with optional 'organiser_attr' describing any organiser identity in more 900 detail. An optional 'tzid' can also be provided. 901 """ 902 903 details = {} 904 905 if organiser: 906 details["UID"] = [(make_uid(organiser), {})] 907 details["ORGANIZER"] = [(organiser, organiser_attr or {})] 908 details["DTSTAMP"] = [(get_timestamp(), {})] 909 910 return Object({object_type : (details, {})}, tzid) 911 912 def make_uid(user): 913 914 "Return a unique identifier for a new object by the given 'user'." 915 916 utcnow = get_timestamp() 917 return "imip-agent-%s-%s" % (utcnow, get_address(user)) 918 919 # Structure access functions. 920 921 def get_items(d, name, all=True): 922 923 """ 924 Get all items from 'd' for the given 'name', returning single items if 925 'all' is specified and set to a false value and if only one value is 926 present for the name. Return None if no items are found for the name or if 927 many items are found but 'all' is set to a false value. 928 """ 929 930 if d.has_key(name): 931 items = [(value or None, attr) for value, attr in d[name]] 932 if all: 933 return items 934 elif len(items) == 1: 935 return items[0] 936 else: 937 return None 938 else: 939 return None 940 941 def get_item(d, name): 942 return get_items(d, name, False) 943 944 def get_value_map(d, name): 945 946 """ 947 Return a dictionary for all items in 'd' having the given 'name'. The 948 dictionary will map values for the name to any attributes or qualifiers 949 that may have been present. 950 """ 951 952 items = get_items(d, name) 953 if items: 954 return dict(items) 955 else: 956 return {} 957 958 def values_from_items(items): 959 return map(lambda x: x[0], items) 960 961 def get_values(d, name, all=True): 962 if d.has_key(name): 963 items = d[name] 964 if not all and len(items) == 1: 965 return items[0][0] 966 else: 967 return values_from_items(items) 968 else: 969 return None 970 971 def get_value(d, name): 972 return get_values(d, name, False) 973 974 def get_date_value_items(d, name, tzid=None): 975 976 """ 977 Obtain items from 'd' having the given 'name', where a single item yields 978 potentially many values. Return a list of tuples of the form (value, 979 attributes) where the attributes have been given for the property in 'd'. 980 """ 981 982 items = get_items(d, name) 983 if items: 984 all_items = [] 985 for item in items: 986 values, attr = item 987 if not attr.has_key("TZID") and tzid: 988 attr["TZID"] = tzid 989 if not isinstance(values, list): 990 values = [values] 991 for value in values: 992 all_items.append((get_datetime(value, attr) or get_period(value, attr), attr)) 993 return all_items 994 else: 995 return None 996 997 def get_date_value_item_periods(d, name, duration, tzid=None): 998 999 """ 1000 Obtain items from 'd' having the given 'name', where a single item yields 1001 potentially many values. The 'duration' must be provided to define the 1002 length of periods having only a start datetime. Return a list of periods 1003 corresponding to the property in 'd'. 1004 """ 1005 1006 items = get_date_value_items(d, name, tzid) 1007 if not items: 1008 return items 1009 1010 periods = [] 1011 1012 for value, attr in items: 1013 if isinstance(value, tuple): 1014 periods.append(RecurringPeriod(value[0], value[1], tzid, name, attr)) 1015 else: 1016 periods.append(RecurringPeriod(value, value + duration, tzid, name, attr)) 1017 1018 return periods 1019 1020 def get_period_values(d, name, tzid=None): 1021 1022 """ 1023 Return period values from 'd' for the given property 'name', using 'tzid' 1024 where specified to indicate the time zone. 1025 """ 1026 1027 values = [] 1028 for value, attr in get_items(d, name) or []: 1029 if not attr.has_key("TZID") and tzid: 1030 attr["TZID"] = tzid 1031 start, end = get_period(value, attr) 1032 values.append(Period(start, end, tzid=tzid)) 1033 return values 1034 1035 def get_utc_datetime(d, name, date_tzid=None): 1036 1037 """ 1038 Return the value provided by 'd' for 'name' as a datetime in the UTC zone 1039 or as a date, converting any date to a datetime if 'date_tzid' is specified. 1040 If no datetime or date is available, None is returned. 1041 """ 1042 1043 t = get_datetime_item(d, name) 1044 if not t: 1045 return None 1046 else: 1047 dt, attr = t 1048 return dt is not None and to_utc_datetime(dt, date_tzid) or None 1049 1050 def get_datetime_item(d, name): 1051 1052 """ 1053 Return the value provided by 'd' for 'name' as a datetime or as a date, 1054 together with the attributes describing it. Return None if no value exists 1055 for 'name' in 'd'. 1056 """ 1057 1058 t = get_item(d, name) 1059 if not t: 1060 return None 1061 else: 1062 value, attr = t 1063 dt = get_datetime(value, attr) 1064 tzid = get_datetime_tzid(dt) 1065 if tzid: 1066 attr["TZID"] = tzid 1067 return dt, attr 1068 1069 # Conversion functions. 1070 1071 def get_address_parts(values): 1072 1073 "Return name and address tuples for each of the given 'values'." 1074 1075 l = [] 1076 for name, address in values and email.utils.getaddresses(values) or []: 1077 if is_mailto_uri(name): 1078 name = name[7:] # strip "mailto:" 1079 l.append((name, address)) 1080 return l 1081 1082 def get_addresses(values): 1083 1084 """ 1085 Return only addresses from the given 'values' which may be of the form 1086 "Common Name <recipient@domain>", with the latter part being the address 1087 itself. 1088 """ 1089 1090 return [address for name, address in get_address_parts(values)] 1091 1092 def get_address(value): 1093 1094 "Return an e-mail address from the given 'value'." 1095 1096 if not value: return None 1097 return get_addresses([value])[0] 1098 1099 def get_verbose_address(value, attr=None): 1100 1101 """ 1102 Return a verbose e-mail address featuring any name from the given 'value' 1103 and any accompanying 'attr' dictionary. 1104 """ 1105 1106 l = get_address_parts([value]) 1107 if not l: 1108 return value 1109 name, address = l[0] 1110 if not name: 1111 name = attr and attr.get("CN") 1112 if name and address: 1113 return "%s <%s>" % (name, address) 1114 else: 1115 return address 1116 1117 def is_mailto_uri(value): 1118 1119 """ 1120 Return whether 'value' is a mailto: URI, with the protocol potentially being 1121 in upper case. 1122 """ 1123 1124 return value.lower().startswith("mailto:") 1125 1126 def get_uri(value): 1127 1128 "Return a URI for the given 'value'." 1129 1130 if not value: return None 1131 1132 # Normalise to "mailto:" or return other URI form. 1133 1134 return is_mailto_uri(value) and ("mailto:%s" % value[7:]) or \ 1135 ":" in value and value or \ 1136 "mailto:%s" % get_address(value) 1137 1138 def uri_parts(values): 1139 1140 "Return any common name plus the URI for each of the given 'values'." 1141 1142 return [(name, get_uri(address)) for name, address in get_address_parts(values)] 1143 1144 uri_value = get_uri 1145 1146 def uri_values(values): 1147 return map(get_uri, values) 1148 1149 def uri_dict(d): 1150 return dict([(get_uri(key), value) for key, value in d.items()]) 1151 1152 def uri_item(item): 1153 return get_uri(item[0]), item[1] 1154 1155 def uri_items(items): 1156 return [(get_uri(value), attr) for value, attr in items] 1157 1158 # Operations on structure data. 1159 1160 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, ignore_dtstamp): 1161 1162 """ 1163 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 1164 'new_dtstamp', and the 'ignore_dtstamp' indication, whether the object 1165 providing the new information is really newer than the object providing the 1166 old information. 1167 """ 1168 1169 have_sequence = old_sequence is not None and new_sequence is not None 1170 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 1171 1172 have_dtstamp = old_dtstamp and new_dtstamp 1173 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 1174 1175 is_old_sequence = have_sequence and ( 1176 int(new_sequence) < int(old_sequence) or 1177 is_same_sequence and is_old_dtstamp 1178 ) 1179 1180 return is_same_sequence and ignore_dtstamp or not is_old_sequence 1181 1182 def check_delegation(attendee_map, attendee, attendee_attr): 1183 1184 """ 1185 Using the 'attendee_map', check the attributes for the given 'attendee' 1186 provided as 'attendee_attr', following the delegation chain back to the 1187 delegators and forward again to yield the delegate identities in each 1188 case. Pictorially... 1189 1190 attendee -> DELEGATED-FROM -> delegator 1191 ? <- DELEGATED-TO <--- 1192 1193 Return whether 'attendee' was identified as a delegate by providing the 1194 identity of any delegators referencing the attendee. 1195 """ 1196 1197 delegators = [] 1198 1199 # The recipient should have a reference to the delegator. 1200 1201 delegated_from = attendee_attr and attendee_attr.get("DELEGATED-FROM") 1202 if delegated_from: 1203 1204 # Examine all delegators. 1205 1206 for delegator in delegated_from: 1207 delegator_attr = attendee_map.get(delegator) 1208 1209 # The delegator should have a reference to the recipient. 1210 1211 delegated_to = delegator_attr and delegator_attr.get("DELEGATED-TO") 1212 if delegated_to and attendee in delegated_to: 1213 delegators.append(delegator) 1214 1215 return delegators 1216 1217 def make_rule_period(start, duration, attr, tzid): 1218 1219 """ 1220 Make a period for the rule period starting at 'start' with the given 1221 'duration' employing the given datetime 'attr' and 'tzid'. 1222 """ 1223 1224 # Determine the resolution of the period. 1225 1226 create = len(start) == 3 and date or datetime 1227 start = to_timezone(create(*start), tzid) 1228 end = start + duration 1229 1230 # Create the period with accompanying metadata based on the main 1231 # period and event details. 1232 1233 return RecurringPeriod(start, end, tzid, "RRULE", attr) 1234 1235 def get_rule_periods(rrule, main_period, tzid, end, inclusive=False): 1236 1237 """ 1238 Return periods for the given 'rrule', employing the 'main_period' and 1239 'tzid'. 1240 1241 The specified 'end' datetime indicates the end of the window for which 1242 periods shall be computed. 1243 1244 If 'inclusive' is set to a true value, any period occurring at the 'end' 1245 will be included. 1246 """ 1247 1248 start = main_period.get_start() 1249 attr = main_period.get_start_attr() 1250 duration = main_period.get_duration() 1251 1252 parameters = rrule and get_parameters(rrule) 1253 selector = get_rule(start, rrule) 1254 1255 until = parameters.get("UNTIL") 1256 1257 if until: 1258 until_dt = to_timezone(get_datetime(until, attr), tzid) 1259 end = end and min(until_dt, end) or until_dt 1260 inclusive = True 1261 1262 # Obtain period instances, starting from the main period. Since counting 1263 # must start from the first period, filtering from a start date must be 1264 # done after the instances have been obtained. 1265 1266 periods = [] 1267 1268 for recurrence_start in selector.materialise(start, end, 1269 parameters.get("COUNT"), 1270 parameters.get("BYSETPOS"), 1271 inclusive): 1272 1273 periods.append(make_rule_period(recurrence_start, duration, attr, tzid)) 1274 1275 return periods 1276 1277 def get_periods(obj, start=None, end=None, inclusive=False): 1278 1279 """ 1280 Return periods for the given object 'obj', employing the object's fallback 1281 time zone where no time zone information is available (for whole day events, 1282 for example), confining materialised periods to after the given 'start' 1283 datetime and before the given 'end' datetime. 1284 1285 If 'end' is omitted, only explicit recurrences and recurrences from 1286 explicitly-terminated rules will be returned. 1287 1288 If 'inclusive' is set to a true value, any period occurring at the 'end' 1289 will be included. 1290 """ 1291 1292 tzid = obj.get_tzid() 1293 rrule = obj.get_value("RRULE") 1294 parameters = rrule and get_parameters(rrule) 1295 1296 # Use localised datetimes. 1297 1298 main_period = obj.get_main_period() 1299 1300 if not rrule: 1301 periods = [main_period] 1302 1303 # Recurrence rules create multiple instances to be checked. 1304 # Conflicts may only be assessed within a period defined by policy 1305 # for the agent, with instances outside that period being considered 1306 # unchecked. 1307 1308 elif end or parameters and parameters.has_key("UNTIL") or parameters.has_key("COUNT"): 1309 1310 # Define a selection period with a start point. The end will be handled 1311 # in the materialisation process. 1312 1313 selection_period = Period(start, None) 1314 periods = [] 1315 1316 for period in get_rule_periods(rrule, main_period, tzid, end, 1317 inclusive): 1318 1319 # Use the main period where it occurs. 1320 1321 if period == main_period: 1322 period = main_period 1323 1324 # Filter out periods before the start. 1325 1326 if period.within(selection_period): 1327 periods.append(period) 1328 1329 else: 1330 periods = [] 1331 1332 # Add recurrence dates. 1333 1334 rdates = obj.get_date_value_item_periods("RDATE") 1335 if rdates: 1336 periods += rdates 1337 1338 # Return a sorted list of the periods. 1339 1340 periods.sort() 1341 1342 # Exclude exception dates. 1343 1344 exdates = obj.get_date_value_item_periods("EXDATE") 1345 1346 if exdates: 1347 for period in exdates: 1348 i = bisect_left(periods, period) 1349 while i < len(periods) and periods[i] == period: 1350 del periods[i] 1351 1352 return periods 1353 1354 def get_main_period(periods): 1355 1356 "Return the main period from 'periods' using origin information." 1357 1358 for p in periods: 1359 if p.origin == "DTSTART": 1360 return p 1361 return None 1362 1363 def get_recurrence_periods(periods): 1364 1365 "Return recurrence periods from 'periods' using origin information." 1366 1367 l = [] 1368 for p in periods: 1369 if p.origin != "DTSTART": 1370 l.append(p) 1371 return l 1372 1373 def get_sender_identities(mapping): 1374 1375 """ 1376 Return a mapping from actual senders to the identities for which they 1377 have provided data, extracting this information from the given 1378 'mapping'. The SENT-BY attribute provides sender information in preference 1379 to the property values given as the mapping keys. 1380 """ 1381 1382 senders = {} 1383 1384 for value, attr in mapping.items(): 1385 sent_by = attr.get("SENT-BY") 1386 if sent_by: 1387 sender = get_uri(sent_by) 1388 else: 1389 sender = value 1390 1391 if not senders.has_key(sender): 1392 senders[sender] = [] 1393 1394 senders[sender].append(value) 1395 1396 return senders 1397 1398 def get_window_end(tzid, days=100, start=None): 1399 1400 """ 1401 Return a datetime in the time zone indicated by 'tzid' marking the end of a 1402 window of the given number of 'days'. If 'start' is not indicated, the start 1403 of the window will be the current moment. 1404 """ 1405 1406 return to_timezone(start or datetime.now(), tzid) + timedelta(days) 1407 1408 def update_attendees_with_delegates(stored_attendees, attendees): 1409 1410 """ 1411 Update the 'stored_attendees' mapping with delegate information from the 1412 given 'attendees' mapping. 1413 """ 1414 1415 # Check for delegated attendees. 1416 1417 for attendee, attendee_attr in attendees.items(): 1418 1419 # Identify delegates and check the delegation using the updated 1420 # attendee information. 1421 1422 if not stored_attendees.has_key(attendee) and \ 1423 attendee_attr.has_key("DELEGATED-FROM") and \ 1424 check_delegation(stored_attendees, attendee, attendee_attr): 1425 1426 stored_attendees[attendee] = attendee_attr 1427 1428 # vim: tabstop=4 expandtab shiftwidth=4