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