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