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