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