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 datetime import datetime, timedelta 23 from email.mime.text import MIMEText 24 from imiptools.dates import format_datetime, get_datetime, get_freebusy_period, \ 25 to_utc_datetime 26 from vCalendar import iterwrite, parse, ParseError, to_dict, to_node 27 from vRecurrence import get_parameters, get_rule 28 import email.utils 29 30 try: 31 from cStringIO import StringIO 32 except ImportError: 33 from StringIO import StringIO 34 35 class Object: 36 37 "Access to calendar structures." 38 39 def __init__(self, fragment): 40 self.objtype, (self.details, self.attr) = fragment.items()[0] 41 42 def get_items(self, name, all=True): 43 return get_items(self.details, name, all) 44 45 def get_item(self, name): 46 return get_item(self.details, name) 47 48 def get_value_map(self, name): 49 return get_value_map(self.details, name) 50 51 def get_values(self, name, all=True): 52 return get_values(self.details, name, all) 53 54 def get_value(self, name): 55 return get_value(self.details, name) 56 57 def get_utc_datetime(self, name): 58 return get_utc_datetime(self.details, name) 59 60 def get_datetime_item(self, name): 61 return get_datetime_item(self.details, name) 62 63 def to_node(self): 64 return to_node({self.objtype : [(self.details, self.attr)]}) 65 66 def to_part(self, method): 67 return to_part(method, [self.to_node()]) 68 69 # Direct access to the structure. 70 71 def __getitem__(self, name): 72 return self.details[name] 73 74 def __setitem__(self, name, value): 75 self.details[name] = value 76 77 def __delitem__(self, name): 78 del self.details[name] 79 80 # Computed results. 81 82 def get_periods(self, window_size=100): 83 return get_periods(self, window_size) 84 85 def get_periods_for_freebusy(self, tzid, window_size=100): 86 periods = self.get_periods(window_size) 87 return get_periods_for_freebusy(self, periods, tzid) 88 89 # Construction and serialisation. 90 91 def make_calendar(nodes, method=None): 92 93 """ 94 Return a complete calendar node wrapping the given 'nodes' and employing the 95 given 'method', if indicated. 96 """ 97 98 return ("VCALENDAR", {}, 99 (method and [("METHOD", {}, method)] or []) + 100 [("VERSION", {}, "2.0")] + 101 nodes 102 ) 103 104 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None, attendee_attr=None): 105 106 """ 107 Return a calendar node defining the free/busy details described in the given 108 'freebusy' list, employing the given 'uid', for the given 'organiser' and 109 optional 'organiser_attr', with the optional 'attendee' providing recipient 110 details together with the optional 'attendee_attr'. 111 """ 112 113 record = [] 114 rwrite = record.append 115 116 rwrite(("ORGANIZER", organiser_attr or {}, organiser)) 117 118 if attendee: 119 rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 120 121 rwrite(("UID", {}, uid)) 122 123 if freebusy: 124 for start, end, uid, transp in freebusy: 125 if transp == "OPAQUE": 126 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join([start, end]))) 127 128 return ("VFREEBUSY", {}, record) 129 130 def parse_object(f, encoding, objtype=None): 131 132 """ 133 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 134 given, only objects of that type will be returned. Otherwise, the root of 135 the content will be returned as a dictionary with a single key indicating 136 the object type. 137 138 Return None if the content was not readable or suitable. 139 """ 140 141 try: 142 try: 143 doctype, attrs, elements = obj = parse(f, encoding=encoding) 144 if objtype and doctype == objtype: 145 return to_dict(obj)[objtype][0] 146 elif not objtype: 147 return to_dict(obj) 148 finally: 149 f.close() 150 151 # NOTE: Handle parse errors properly. 152 153 except (ParseError, ValueError): 154 pass 155 156 return None 157 158 def to_part(method, calendar): 159 160 """ 161 Write using the given 'method', the 'calendar' details to a MIME 162 text/calendar part. 163 """ 164 165 encoding = "utf-8" 166 out = StringIO() 167 try: 168 to_stream(out, make_calendar(calendar, method), encoding) 169 part = MIMEText(out.getvalue(), "calendar", encoding) 170 part.set_param("method", method) 171 return part 172 173 finally: 174 out.close() 175 176 def to_stream(out, fragment, encoding="utf-8"): 177 iterwrite(out, encoding=encoding).append(fragment) 178 179 # Structure access functions. 180 181 def get_items(d, name, all=True): 182 183 """ 184 Get all items from 'd' for the given 'name', returning single items if 185 'all' is specified and set to a false value and if only one value is 186 present for the name. Return None if no items are found for the name or if 187 many items are found but 'all' is set to a false value. 188 """ 189 190 if d.has_key(name): 191 values = d[name] 192 if all: 193 return values 194 elif len(values) == 1: 195 return values[0] 196 else: 197 return None 198 else: 199 return None 200 201 def get_item(d, name): 202 return get_items(d, name, False) 203 204 def get_value_map(d, name): 205 206 """ 207 Return a dictionary for all items in 'd' having the given 'name'. The 208 dictionary will map values for the name to any attributes or qualifiers 209 that may have been present. 210 """ 211 212 items = get_items(d, name) 213 if items: 214 return dict(items) 215 else: 216 return {} 217 218 def get_values(d, name, all=True): 219 if d.has_key(name): 220 values = d[name] 221 if not all and len(values) == 1: 222 return values[0][0] 223 else: 224 return map(lambda x: x[0], values) 225 else: 226 return None 227 228 def get_value(d, name): 229 return get_values(d, name, False) 230 231 def get_utc_datetime(d, name): 232 dt, attr = get_datetime_item(d, name) 233 return to_utc_datetime(dt) 234 235 def get_datetime_item(d, name): 236 value, attr = get_item(d, name) 237 return get_datetime(value, attr), attr 238 239 def get_addresses(values): 240 return [address for name, address in email.utils.getaddresses(values)] 241 242 def get_address(value): 243 return value.lower().startswith("mailto:") and value.lower()[7:] or value 244 245 def get_uri(value): 246 return value.lower().startswith("mailto:") and value.lower() or ":" in value and value or "mailto:%s" % value.lower() 247 248 uri_value = get_uri 249 250 def uri_values(values): 251 return map(get_uri, values) 252 253 def uri_dict(d): 254 return dict([(get_uri(key), value) for key, value in d.items()]) 255 256 def uri_item(item): 257 return get_uri(item[0]), item[1] 258 259 def uri_items(items): 260 return [(get_uri(value), attr) for value, attr in items] 261 262 # Operations on structure data. 263 264 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, partstat_set): 265 266 """ 267 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 268 'new_dtstamp', and the 'partstat_set' indication, whether the object 269 providing the new information is really newer than the object providing the 270 old information. 271 """ 272 273 have_sequence = old_sequence is not None and new_sequence is not None 274 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 275 276 have_dtstamp = old_dtstamp and new_dtstamp 277 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 278 279 is_old_sequence = have_sequence and ( 280 int(new_sequence) < int(old_sequence) or 281 is_same_sequence and is_old_dtstamp 282 ) 283 284 return is_same_sequence and partstat_set or not is_old_sequence 285 286 # NOTE: Need to expose the 100 day window for recurring events in the 287 # NOTE: configuration. 288 289 def get_periods(obj, window_size=100): 290 291 """ 292 Return periods for the given object 'obj', confining materialised periods 293 to the given 'window_size' in days starting from the present moment. 294 """ 295 296 dtstart = obj.get_utc_datetime("DTSTART") 297 dtend = obj.get_utc_datetime("DTEND") 298 299 # NOTE: Need also DURATION support. 300 301 duration = dtend - dtstart 302 303 # Recurrence rules create multiple instances to be checked. 304 # Conflicts may only be assessed within a period defined by policy 305 # for the agent, with instances outside that period being considered 306 # unchecked. 307 308 window_end = datetime.now() + timedelta(window_size) 309 310 # NOTE: Need also RDATE and EXDATE support. 311 312 rrule = obj.get_value("RRULE") 313 314 if rrule: 315 selector = get_rule(dtstart, rrule) 316 parameters = get_parameters(rrule) 317 periods = [] 318 for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")): 319 start = datetime(*start, tzinfo=timezone("UTC")) 320 end = start + duration 321 periods.append((start, end)) 322 else: 323 periods = [(dtstart, dtend)] 324 325 return periods 326 327 def get_periods_for_freebusy(obj, periods, tzid): 328 329 """ 330 Get free/busy-compliant periods employed by 'obj' from the given 'periods', 331 using the indicated 'tzid' to convert dates to datetimes. 332 """ 333 334 start, start_attr = obj.get_datetime_item("DTSTART") 335 end, end_attr = obj.get_datetime_item("DTEND") 336 337 tzid = start_attr.get("TZID") or end_attr.get("TZID") or tzid 338 339 l = [] 340 341 for start, end in periods: 342 start, end = get_freebusy_period(start, end, tzid) 343 l.append((format_datetime(start), format_datetime(end))) 344 345 return l 346 347 # vim: tabstop=4 expandtab shiftwidth=4