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