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