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