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