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