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