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