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