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