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 336 if not rrule: 337 return False 338 elif not until: 339 return True 340 341 dtstart, dtstart_attr = self.get_datetime_item("DTSTART") 342 until = get_datetime(until, dtstart_attr) 343 return until > dt 344 345 # Modification methods. 346 347 def set_datetime(self, name, dt, tzid=None): 348 349 """ 350 Set a datetime for property 'name' using 'dt' and the optional fallback 351 'tzid', returning whether an update has occurred. 352 """ 353 354 if dt: 355 old_value = self.get_value(name) 356 self[name] = [get_item_from_datetime(dt, tzid)] 357 return format_datetime(dt) != old_value 358 359 return False 360 361 def set_period(self, period): 362 363 "Set the given 'period' as the main start and end." 364 365 result = self.set_datetime("DTSTART", period.get_start()) 366 result = self.set_datetime("DTEND", period.get_end()) or result 367 return result 368 369 def set_periods(self, periods): 370 371 """ 372 Set the given 'periods' as recurrence date properties, replacing the 373 previous RDATE properties and ignoring any RRULE properties. 374 """ 375 376 update = False 377 378 old_values = self.get_values("RDATE") 379 new_rdates = [] 380 381 if self.has_key("RDATE"): 382 del self["RDATE"] 383 384 for p in periods: 385 if p.origin != "RRULE": 386 new_rdates.append(get_period_item(p.get_start(), p.get_end())) 387 388 self["RDATE"] = new_rdates 389 390 # NOTE: To do: calculate the update status. 391 return update 392 393 # Construction and serialisation. 394 395 def make_calendar(nodes, method=None): 396 397 """ 398 Return a complete calendar node wrapping the given 'nodes' and employing the 399 given 'method', if indicated. 400 """ 401 402 return ("VCALENDAR", {}, 403 (method and [("METHOD", {}, method)] or []) + 404 [("VERSION", {}, "2.0")] + 405 nodes 406 ) 407 408 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None, 409 attendee_attr=None, period=None): 410 411 """ 412 Return a calendar node defining the free/busy details described in the given 413 'freebusy' list, employing the given 'uid', for the given 'organiser' and 414 optional 'organiser_attr', with the optional 'attendee' providing recipient 415 details together with the optional 'attendee_attr'. 416 417 The result will be constrained to the 'period' if specified. 418 """ 419 420 record = [] 421 rwrite = record.append 422 423 rwrite(("ORGANIZER", organiser_attr or {}, organiser)) 424 425 if attendee: 426 rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 427 428 rwrite(("UID", {}, uid)) 429 430 if freebusy: 431 432 # Get a constrained view if start and end limits are specified. 433 434 if period: 435 periods = period_overlaps(freebusy, period, True) 436 else: 437 periods = freebusy 438 439 # Write the limits of the resource. 440 441 if periods: 442 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start_point()))) 443 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end_point()))) 444 else: 445 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_start_point()))) 446 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_end_point()))) 447 448 for p in periods: 449 if p.transp == "OPAQUE": 450 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 451 map(format_datetime, [p.get_start_point(), p.get_end_point()]) 452 ))) 453 454 return ("VFREEBUSY", {}, record) 455 456 def parse_object(f, encoding, objtype=None): 457 458 """ 459 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 460 given, only objects of that type will be returned. Otherwise, the root of 461 the content will be returned as a dictionary with a single key indicating 462 the object type. 463 464 Return None if the content was not readable or suitable. 465 """ 466 467 try: 468 try: 469 doctype, attrs, elements = obj = parse(f, encoding=encoding) 470 if objtype and doctype == objtype: 471 return to_dict(obj)[objtype][0] 472 elif not objtype: 473 return to_dict(obj) 474 finally: 475 f.close() 476 477 # NOTE: Handle parse errors properly. 478 479 except (ParseError, ValueError): 480 pass 481 482 return None 483 484 def to_part(method, calendar): 485 486 """ 487 Write using the given 'method', the 'calendar' details to a MIME 488 text/calendar part. 489 """ 490 491 encoding = "utf-8" 492 out = StringIO() 493 try: 494 to_stream(out, make_calendar(calendar, method), encoding) 495 part = MIMEText(out.getvalue(), "calendar", encoding) 496 part.set_param("method", method) 497 return part 498 499 finally: 500 out.close() 501 502 def to_stream(out, fragment, encoding="utf-8"): 503 iterwrite(out, encoding=encoding).append(fragment) 504 505 # Structure access functions. 506 507 def get_items(d, name, all=True): 508 509 """ 510 Get all items from 'd' for the given 'name', returning single items if 511 'all' is specified and set to a false value and if only one value is 512 present for the name. Return None if no items are found for the name or if 513 many items are found but 'all' is set to a false value. 514 """ 515 516 if d.has_key(name): 517 items = d[name] 518 if all: 519 return items 520 elif len(items) == 1: 521 return items[0] 522 else: 523 return None 524 else: 525 return None 526 527 def get_item(d, name): 528 return get_items(d, name, False) 529 530 def get_value_map(d, name): 531 532 """ 533 Return a dictionary for all items in 'd' having the given 'name'. The 534 dictionary will map values for the name to any attributes or qualifiers 535 that may have been present. 536 """ 537 538 items = get_items(d, name) 539 if items: 540 return dict(items) 541 else: 542 return {} 543 544 def values_from_items(items): 545 return map(lambda x: x[0], items) 546 547 def get_values(d, name, all=True): 548 if d.has_key(name): 549 items = d[name] 550 if not all and len(items) == 1: 551 return items[0][0] 552 else: 553 return values_from_items(items) 554 else: 555 return None 556 557 def get_value(d, name): 558 return get_values(d, name, False) 559 560 def get_date_value_items(d, name, tzid=None): 561 562 """ 563 Obtain items from 'd' having the given 'name', where a single item yields 564 potentially many values. Return a list of tuples of the form (value, 565 attributes) where the attributes have been given for the property in 'd'. 566 """ 567 568 items = get_items(d, name) 569 if items: 570 all_items = [] 571 for item in items: 572 values, attr = item 573 if not attr.has_key("TZID") and tzid: 574 attr["TZID"] = tzid 575 if not isinstance(values, list): 576 values = [values] 577 for value in values: 578 all_items.append((get_datetime(value, attr) or get_period(value, attr), attr)) 579 return all_items 580 else: 581 return None 582 583 def get_period_values(d, name, tzid=None): 584 585 """ 586 Return period values from 'd' for the given property 'name', using 'tzid' 587 where specified to indicate the time zone. 588 """ 589 590 values = [] 591 for value, attr in get_items(d, name) or []: 592 if not attr.has_key("TZID") and tzid: 593 attr["TZID"] = tzid 594 start, end = get_period(value, attr) 595 values.append(Period(start, end, tzid=tzid)) 596 return values 597 598 def get_utc_datetime(d, name, date_tzid=None): 599 600 """ 601 Return the value provided by 'd' for 'name' as a datetime in the UTC zone 602 or as a date, converting any date to a datetime if 'date_tzid' is specified. 603 """ 604 605 t = get_datetime_item(d, name) 606 if not t: 607 return None 608 else: 609 dt, attr = t 610 return to_utc_datetime(dt, date_tzid) 611 612 def get_datetime_item(d, name): 613 614 """ 615 Return the value provided by 'd' for 'name' as a datetime or as a date, 616 together with the attributes describing it. Return None if no value exists 617 for 'name' in 'd'. 618 """ 619 620 t = get_item(d, name) 621 if not t: 622 return None 623 else: 624 value, attr = t 625 dt = get_datetime(value, attr) 626 tzid = get_datetime_tzid(dt) 627 if tzid: 628 attr["TZID"] = tzid 629 return dt, attr 630 631 # Conversion functions. 632 633 def get_addresses(values): 634 return [address for name, address in email.utils.getaddresses(values)] 635 636 def get_address(value): 637 value = value.lower() 638 return value.startswith("mailto:") and value[7:] or value 639 640 def get_uri(value): 641 return value.lower().startswith("mailto:") and value.lower() or ":" in value and value or "mailto:%s" % value.lower() 642 643 uri_value = get_uri 644 645 def uri_values(values): 646 return map(get_uri, values) 647 648 def uri_dict(d): 649 return dict([(get_uri(key), value) for key, value in d.items()]) 650 651 def uri_item(item): 652 return get_uri(item[0]), item[1] 653 654 def uri_items(items): 655 return [(get_uri(value), attr) for value, attr in items] 656 657 # Operations on structure data. 658 659 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, partstat_set): 660 661 """ 662 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 663 'new_dtstamp', and the 'partstat_set' indication, whether the object 664 providing the new information is really newer than the object providing the 665 old information. 666 """ 667 668 have_sequence = old_sequence is not None and new_sequence is not None 669 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 670 671 have_dtstamp = old_dtstamp and new_dtstamp 672 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 673 674 is_old_sequence = have_sequence and ( 675 int(new_sequence) < int(old_sequence) or 676 is_same_sequence and is_old_dtstamp 677 ) 678 679 return is_same_sequence and partstat_set or not is_old_sequence 680 681 def get_periods(obj, tzid, end=None, inclusive=False): 682 683 """ 684 Return periods for the given object 'obj', employing the given 'tzid' where 685 no time zone information is available (for whole day events, for example), 686 confining materialised periods to before the given 'end' datetime. 687 688 If 'end' is omitted, only explicit recurrences and recurrences from 689 explicitly-terminated rules will be returned. 690 691 If 'inclusive' is set to a true value, any period occurring at the 'end' 692 will be included. 693 """ 694 695 rrule = obj.get_value("RRULE") 696 parameters = rrule and get_parameters(rrule) 697 698 # Use localised datetimes. 699 700 (dtstart, dtstart_attr), (dtend, dtend_attr) = obj.get_main_period_items(tzid) 701 duration = dtend - dtstart 702 703 # Attempt to get time zone details from the object, using the supplied zone 704 # only as a fallback. 705 706 obj_tzid = obj.get_tzid() 707 708 if not rrule: 709 periods = [RecurringPeriod(dtstart, dtend, tzid, "DTSTART", dtstart_attr, dtend_attr)] 710 711 elif end or parameters and parameters.has_key("UNTIL") or parameters.has_key("COUNT"): 712 713 # Recurrence rules create multiple instances to be checked. 714 # Conflicts may only be assessed within a period defined by policy 715 # for the agent, with instances outside that period being considered 716 # unchecked. 717 718 selector = get_rule(dtstart, rrule) 719 periods = [] 720 721 until = parameters.get("UNTIL") 722 if until: 723 until_dt = to_timezone(get_datetime(until, dtstart_attr), obj_tzid) 724 end = end and min(until_dt, end) or until_dt 725 inclusive = True 726 727 for recurrence_start in selector.materialise(dtstart, end, parameters.get("COUNT"), parameters.get("BYSETPOS"), inclusive): 728 create = len(recurrence_start) == 3 and date or datetime 729 recurrence_start = to_timezone(create(*recurrence_start), obj_tzid) 730 recurrence_end = recurrence_start + duration 731 periods.append(RecurringPeriod(recurrence_start, recurrence_end, tzid, "RRULE", dtstart_attr)) 732 733 else: 734 periods = [] 735 736 # Add recurrence dates. 737 738 rdates = obj.get_date_value_items("RDATE", tzid) 739 740 if rdates: 741 for rdate, rdate_attr in rdates: 742 if isinstance(rdate, tuple): 743 periods.append(RecurringPeriod(rdate[0], rdate[1], tzid, "RDATE", rdate_attr)) 744 else: 745 periods.append(RecurringPeriod(rdate, rdate + duration, tzid, "RDATE", rdate_attr)) 746 747 # Return a sorted list of the periods. 748 749 periods.sort() 750 751 # Exclude exception dates. 752 753 exdates = obj.get_date_value_items("EXDATE", tzid) 754 755 if exdates: 756 for exdate, exdate_attr in exdates: 757 if isinstance(exdate, tuple): 758 period = RecurringPeriod(exdate[0], exdate[1], tzid, "EXDATE", exdate_attr) 759 else: 760 period = RecurringPeriod(exdate, exdate + duration, tzid, "EXDATE", exdate_attr) 761 i = bisect_left(periods, period) 762 while i < len(periods) and periods[i] == period: 763 del periods[i] 764 765 return periods 766 767 def get_sender_identities(mapping): 768 769 """ 770 Return a mapping from actual senders to the identities for which they 771 have provided data, extracting this information from the given 772 'mapping'. 773 """ 774 775 senders = {} 776 777 for value, attr in mapping.items(): 778 sent_by = attr.get("SENT-BY") 779 if sent_by: 780 sender = get_uri(sent_by) 781 else: 782 sender = value 783 784 if not senders.has_key(sender): 785 senders[sender] = [] 786 787 senders[sender].append(value) 788 789 return senders 790 791 def get_window_end(tzid, days=100): 792 793 """ 794 Return a datetime in the time zone indicated by 'tzid' marking the end of a 795 window of the given number of 'days'. 796 """ 797 798 return to_timezone(datetime.now(), tzid) + timedelta(days) 799 800 # vim: tabstop=4 expandtab shiftwidth=4