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