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