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