imip-agent

Annotated imiptools/data.py

787:9cf10fe21c3a
2015-09-28 Paul Boddie Separated attendee/recurrence manipulation from presentation, introducing form field dictionary updates from form period/date objects, also simplifying the processing of attendees, removing filtering operations during editing. imipweb-client-simplification
paul@213 1
#!/usr/bin/env python
paul@213 2
paul@213 3
"""
paul@213 4
Interpretation of vCalendar content.
paul@213 5
paul@213 6
Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
paul@213 7
paul@213 8
This program is free software; you can redistribute it and/or modify it under
paul@213 9
the terms of the GNU General Public License as published by the Free Software
paul@213 10
Foundation; either version 3 of the License, or (at your option) any later
paul@213 11
version.
paul@213 12
paul@213 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@213 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@213 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@213 16
details.
paul@213 17
paul@213 18
You should have received a copy of the GNU General Public License along with
paul@213 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@213 20
"""
paul@213 21
paul@424 22
from bisect import bisect_left
paul@560 23
from datetime import date, datetime, timedelta
paul@213 24
from email.mime.text import MIMEText
paul@669 25
from imiptools.dates import check_permitted_values, correct_datetime, \
paul@661 26
                            format_datetime, get_datetime, \
paul@625 27
                            get_datetime_item as get_item_from_datetime, \
paul@625 28
                            get_datetime_tzid, \
paul@628 29
                            get_duration, get_period, get_period_item, \
paul@627 30
                            get_recurrence_start_point, \
paul@627 31
                            get_tzid, to_datetime, to_timezone, to_utc_datetime
paul@648 32
from imiptools.period import FreeBusyPeriod, Period, RecurringPeriod, period_overlaps
paul@213 33
from vCalendar import iterwrite, parse, ParseError, to_dict, to_node
paul@256 34
from vRecurrence import get_parameters, get_rule
paul@213 35
import email.utils
paul@213 36
paul@213 37
try:
paul@213 38
    from cStringIO import StringIO
paul@213 39
except ImportError:
paul@213 40
    from StringIO import StringIO
paul@213 41
paul@213 42
class Object:
paul@213 43
paul@213 44
    "Access to calendar structures."
paul@213 45
paul@213 46
    def __init__(self, fragment):
paul@213 47
        self.objtype, (self.details, self.attr) = fragment.items()[0]
paul@213 48
paul@535 49
    def get_uid(self):
paul@535 50
        return self.get_value("UID")
paul@535 51
paul@535 52
    def get_recurrenceid(self):
paul@563 53
paul@563 54
        """
paul@563 55
        Return the recurrence identifier, normalised to a UTC datetime if
paul@627 56
        specified as a datetime or date with accompanying time zone information,
paul@627 57
        maintained as a date or floating datetime otherwise. If no recurrence
paul@627 58
        identifier is present, None is returned.
paul@627 59
paul@627 60
        Note that this normalised form of the identifier may well not be the
paul@627 61
        same as the originally-specified identifier because that could have been
paul@627 62
        specified using an accompanying TZID attribute, whereas the normalised
paul@627 63
        form is effectively a converted datetime value.
paul@563 64
        """
paul@563 65
paul@627 66
        if not self.has_key("RECURRENCE-ID"):
paul@627 67
            return None
paul@627 68
        dt, attr = self.get_datetime_item("RECURRENCE-ID")
paul@628 69
paul@628 70
        # Coerce any date to a UTC datetime if TZID was specified.
paul@628 71
paul@627 72
        tzid = attr.get("TZID")
paul@627 73
        if tzid:
paul@627 74
            dt = to_timezone(to_datetime(dt, tzid), "UTC")
paul@627 75
        return format_datetime(dt)
paul@627 76
paul@627 77
    def get_recurrence_start_point(self, recurrenceid, tzid):
paul@627 78
paul@627 79
        """
paul@627 80
        Return the start point corresponding to the given 'recurrenceid', using
paul@627 81
        the fallback 'tzid' to define the specific point in time referenced by
paul@627 82
        the recurrence identifier if the identifier has a date representation.
paul@627 83
paul@627 84
        If 'recurrenceid' is given as None, this object's recurrence identifier
paul@627 85
        is used to obtain a start point, but if this object does not provide a
paul@627 86
        recurrence, None is returned.
paul@627 87
paul@627 88
        A start point is typically used to match free/busy periods which are
paul@627 89
        themselves defined in terms of UTC datetimes.
paul@627 90
        """
paul@627 91
paul@627 92
        recurrenceid = recurrenceid or self.get_recurrenceid()
paul@627 93
        if recurrenceid:
paul@627 94
            return get_recurrence_start_point(recurrenceid, tzid)
paul@627 95
        else:
paul@627 96
            return None
paul@535 97
paul@679 98
    def get_recurrence_start_points(self, recurrenceids, tzid):
paul@679 99
        return [self.get_recurrence_start_point(recurrenceid, tzid) for recurrenceid in recurrenceids]
paul@679 100
paul@535 101
    # Structure access.
paul@535 102
paul@524 103
    def copy(self):
paul@524 104
        return Object(to_dict(self.to_node()))
paul@524 105
paul@213 106
    def get_items(self, name, all=True):
paul@213 107
        return get_items(self.details, name, all)
paul@213 108
paul@213 109
    def get_item(self, name):
paul@213 110
        return get_item(self.details, name)
paul@213 111
paul@213 112
    def get_value_map(self, name):
paul@213 113
        return get_value_map(self.details, name)
paul@213 114
paul@213 115
    def get_values(self, name, all=True):
paul@213 116
        return get_values(self.details, name, all)
paul@213 117
paul@213 118
    def get_value(self, name):
paul@213 119
        return get_value(self.details, name)
paul@213 120
paul@506 121
    def get_utc_datetime(self, name, date_tzid=None):
paul@506 122
        return get_utc_datetime(self.details, name, date_tzid)
paul@213 123
paul@417 124
    def get_date_values(self, name, tzid=None):
paul@417 125
        items = get_date_value_items(self.details, name, tzid)
paul@389 126
        return items and [value for value, attr in items]
paul@352 127
paul@417 128
    def get_date_value_items(self, name, tzid=None):
paul@417 129
        return get_date_value_items(self.details, name, tzid)
paul@352 130
paul@646 131
    def get_period_values(self, name, tzid=None):
paul@646 132
        return get_period_values(self.details, name, tzid)
paul@630 133
paul@318 134
    def get_datetime(self, name):
paul@567 135
        t = get_datetime_item(self.details, name)
paul@567 136
        if not t: return None
paul@567 137
        dt, attr = t
paul@318 138
        return dt
paul@318 139
paul@289 140
    def get_datetime_item(self, name):
paul@289 141
        return get_datetime_item(self.details, name)
paul@289 142
paul@392 143
    def get_duration(self, name):
paul@392 144
        return get_duration(self.get_value(name))
paul@392 145
paul@213 146
    def to_node(self):
paul@213 147
        return to_node({self.objtype : [(self.details, self.attr)]})
paul@213 148
paul@213 149
    def to_part(self, method):
paul@213 150
        return to_part(method, [self.to_node()])
paul@213 151
paul@213 152
    # Direct access to the structure.
paul@213 153
paul@392 154
    def has_key(self, name):
paul@392 155
        return self.details.has_key(name)
paul@392 156
paul@524 157
    def get(self, name):
paul@524 158
        return self.details.get(name)
paul@524 159
paul@734 160
    def keys(self):
paul@734 161
        return self.details.keys()
paul@734 162
paul@213 163
    def __getitem__(self, name):
paul@213 164
        return self.details[name]
paul@213 165
paul@213 166
    def __setitem__(self, name, value):
paul@213 167
        self.details[name] = value
paul@213 168
paul@213 169
    def __delitem__(self, name):
paul@213 170
        del self.details[name]
paul@213 171
paul@524 172
    def remove(self, name):
paul@524 173
        try:
paul@524 174
            del self[name]
paul@524 175
        except KeyError:
paul@524 176
            pass
paul@524 177
paul@524 178
    def remove_all(self, names):
paul@524 179
        for name in names:
paul@524 180
            self.remove(name)
paul@524 181
paul@734 182
    def preserve(self, names):
paul@734 183
        for name in self.keys():
paul@734 184
            if not name in names:
paul@734 185
                self.remove(name)
paul@734 186
paul@256 187
    # Computed results.
paul@256 188
paul@650 189
    def get_main_period_items(self, tzid):
paul@650 190
paul@650 191
        """
paul@650 192
        Return two (value, attributes) items corresponding to the main start-end
paul@650 193
        period for the object.
paul@650 194
        """
paul@650 195
paul@650 196
        dtstart, dtstart_attr = self.get_datetime_item("DTSTART")
paul@650 197
paul@650 198
        if self.has_key("DTEND"):
paul@650 199
            dtend, dtend_attr = self.get_datetime_item("DTEND")
paul@650 200
        elif self.has_key("DURATION"):
paul@650 201
            duration = self.get_duration("DURATION")
paul@650 202
            dtend = dtstart + duration
paul@650 203
            dtend_attr = dtstart_attr
paul@650 204
        else:
paul@650 205
            dtend, dtend_attr = dtstart, dtstart_attr
paul@650 206
paul@650 207
        return (dtstart, dtstart_attr), (dtend, dtend_attr)
paul@650 208
paul@630 209
    def get_periods(self, tzid, end=None):
paul@620 210
paul@620 211
        """
paul@620 212
        Return periods defined by this object, employing the given 'tzid' where
paul@620 213
        no time zone information is defined, and limiting the collection to a
paul@620 214
        window of time with the given 'end'.
paul@630 215
paul@630 216
        If 'end' is omitted, only explicit recurrences and recurrences from
paul@630 217
        explicitly-terminated rules will be returned.
paul@620 218
        """
paul@620 219
paul@458 220
        return get_periods(self, tzid, end)
paul@360 221
paul@630 222
    def get_active_periods(self, recurrenceids, tzid, end=None):
paul@630 223
paul@630 224
        """
paul@630 225
        Return all periods specified by this object that are not replaced by
paul@630 226
        those defined by 'recurrenceids', using 'tzid' as a fallback time zone
paul@630 227
        to convert floating dates and datetimes, and using 'end' to indicate the
paul@630 228
        end of the time window within which periods are considered.
paul@630 229
        """
paul@630 230
paul@630 231
        # Specific recurrences yield all specified periods.
paul@630 232
paul@630 233
        periods = self.get_periods(tzid, end)
paul@630 234
paul@630 235
        if self.get_recurrenceid():
paul@630 236
            return periods
paul@630 237
paul@630 238
        # Parent objects need to have their periods tested against redefined
paul@630 239
        # recurrences.
paul@630 240
paul@630 241
        active = []
paul@630 242
paul@630 243
        for p in periods:
paul@630 244
paul@630 245
            # Subtract any recurrences from the free/busy details of a
paul@630 246
            # parent object.
paul@630 247
paul@648 248
            if not p.is_replaced(recurrenceids):
paul@630 249
                active.append(p)
paul@630 250
paul@630 251
        return active
paul@630 252
paul@648 253
    def get_freebusy_period(self, period, only_organiser=False):
paul@648 254
paul@648 255
        """
paul@648 256
        Return a free/busy period for the given 'period' provided by this
paul@648 257
        object, using the 'only_organiser' status to produce a suitable
paul@648 258
        transparency value.
paul@648 259
        """
paul@648 260
paul@648 261
        return FreeBusyPeriod(
paul@648 262
            period.get_start_point(),
paul@648 263
            period.get_end_point(),
paul@648 264
            self.get_value("UID"),
paul@648 265
            only_organiser and "ORG" or self.get_value("TRANSP") or "OPAQUE",
paul@648 266
            self.get_recurrenceid(),
paul@648 267
            self.get_value("SUMMARY"),
paul@648 268
            self.get_value("ORGANIZER")
paul@648 269
            )
paul@648 270
paul@648 271
    def get_participation_status(self, participant):
paul@648 272
paul@648 273
        """
paul@648 274
        Return the participation status of the given 'participant', with the
paul@648 275
        special value "ORG" indicating organiser-only participation.
paul@648 276
        """
paul@648 277
    
paul@648 278
        attendees = self.get_value_map("ATTENDEE")
paul@648 279
        organiser = self.get_value("ORGANIZER")
paul@648 280
paul@692 281
        attendee_attr = attendees.get(participant)
paul@692 282
        if attendee_attr:
paul@692 283
            return attendee_attr.get("PARTSTAT", "NEEDS-ACTION")
paul@692 284
        elif organiser == participant:
paul@692 285
            return "ORG"
paul@648 286
paul@648 287
        return None
paul@648 288
paul@648 289
    def get_participation(self, partstat, include_needs_action=False):
paul@648 290
paul@648 291
        """
paul@648 292
        Return whether 'partstat' indicates some kind of participation in an
paul@648 293
        event. If 'include_needs_action' is specified as a true value, events
paul@648 294
        not yet responded to will be treated as events with tentative
paul@648 295
        participation.
paul@648 296
        """
paul@648 297
paul@648 298
        return not partstat in ("DECLINED", "DELEGATED", "NEEDS-ACTION") or \
paul@648 299
               include_needs_action and partstat == "NEEDS-ACTION" or \
paul@648 300
               partstat == "ORG"
paul@648 301
paul@422 302
    def get_tzid(self):
paul@562 303
paul@562 304
        """
paul@562 305
        Return a time zone identifier used by the start or end datetimes,
paul@562 306
        potentially suitable for converting dates to datetimes.
paul@562 307
        """
paul@562 308
paul@560 309
        if not self.has_key("DTSTART"):
paul@560 310
            return None
paul@422 311
        dtstart, dtstart_attr = self.get_datetime_item("DTSTART")
paul@630 312
        if self.has_key("DTEND"):
paul@630 313
            dtend, dtend_attr = self.get_datetime_item("DTEND")
paul@630 314
        else:
paul@630 315
            dtend_attr = None
paul@422 316
        return get_tzid(dtstart_attr, dtend_attr)
paul@422 317
paul@619 318
    def is_shared(self):
paul@619 319
paul@619 320
        """
paul@619 321
        Return whether this object is shared based on the presence of a SEQUENCE
paul@619 322
        property.
paul@619 323
        """
paul@619 324
paul@619 325
        return self.get_value("SEQUENCE") is not None
paul@619 326
paul@650 327
    def possibly_active_from(self, dt, tzid):
paul@650 328
paul@650 329
        """
paul@650 330
        Return whether the object is possibly active from or after the given
paul@650 331
        datetime 'dt' using 'tzid' to convert any dates or floating datetimes.
paul@650 332
        """
paul@650 333
paul@650 334
        dt = to_datetime(dt, tzid)
paul@650 335
        periods = self.get_periods(tzid)
paul@650 336
paul@650 337
        for p in periods:
paul@650 338
            if p.get_end_point() > dt:
paul@650 339
                return True
paul@650 340
paul@672 341
        return self.possibly_recurring_indefinitely()
paul@672 342
paul@672 343
    def possibly_recurring_indefinitely(self):
paul@672 344
paul@672 345
        "Return whether this object may recur indefinitely."
paul@672 346
paul@650 347
        rrule = self.get_value("RRULE")
paul@650 348
        parameters = rrule and get_parameters(rrule)
paul@650 349
        until = parameters and parameters.get("UNTIL")
paul@651 350
        count = parameters and parameters.get("COUNT")
paul@650 351
paul@672 352
        # Non-recurring periods or constrained recurrences.
paul@651 353
paul@651 354
        if not rrule or until or count:
paul@650 355
            return False
paul@651 356
paul@672 357
        # Unconstrained recurring periods will always lie beyond any specified
paul@651 358
        # datetime.
paul@651 359
paul@651 360
        else:
paul@650 361
            return True
paul@650 362
paul@627 363
    # Modification methods.
paul@627 364
paul@627 365
    def set_datetime(self, name, dt, tzid=None):
paul@627 366
paul@627 367
        """
paul@627 368
        Set a datetime for property 'name' using 'dt' and the optional fallback
paul@627 369
        'tzid', returning whether an update has occurred.
paul@627 370
        """
paul@627 371
paul@627 372
        if dt:
paul@627 373
            old_value = self.get_value(name)
paul@627 374
            self[name] = [get_item_from_datetime(dt, tzid)]
paul@627 375
            return format_datetime(dt) != old_value
paul@627 376
paul@627 377
        return False
paul@627 378
paul@627 379
    def set_period(self, period):
paul@627 380
paul@627 381
        "Set the given 'period' as the main start and end."
paul@627 382
paul@627 383
        result = self.set_datetime("DTSTART", period.get_start())
paul@627 384
        result = self.set_datetime("DTEND", period.get_end()) or result
paul@661 385
        if self.has_key("DURATION"):
paul@661 386
            del self["DURATION"]
paul@661 387
paul@627 388
        return result
paul@627 389
paul@627 390
    def set_periods(self, periods):
paul@627 391
paul@627 392
        """
paul@627 393
        Set the given 'periods' as recurrence date properties, replacing the
paul@627 394
        previous RDATE properties and ignoring any RRULE properties.
paul@627 395
        """
paul@627 396
paul@753 397
        old_values = set(self.get_date_values("RDATE") or [])
paul@627 398
        new_rdates = []
paul@627 399
paul@627 400
        if self.has_key("RDATE"):
paul@627 401
            del self["RDATE"]
paul@627 402
paul@627 403
        for p in periods:
paul@627 404
            if p.origin != "RRULE":
paul@627 405
                new_rdates.append(get_period_item(p.get_start(), p.get_end()))
paul@627 406
paul@661 407
        if new_rdates:
paul@661 408
            self["RDATE"] = new_rdates
paul@661 409
paul@753 410
        return old_values != set(self.get_date_values("RDATE") or [])
paul@661 411
paul@784 412
    def update_exceptions(self, excluded):
paul@784 413
paul@784 414
        """
paul@784 415
        Update the exceptions to any rule by applying the list of 'excluded'
paul@784 416
        periods.
paul@784 417
        """
paul@784 418
paul@784 419
        to_exclude = set(excluded).difference(self.get_date_values("EXDATE") or [])
paul@784 420
        if not to_exclude:
paul@784 421
            return False
paul@784 422
paul@784 423
        if not self.has_key("EXDATE"):
paul@784 424
            self["EXDATE"] = []
paul@784 425
paul@784 426
        for p in to_exclude:
paul@784 427
            self["EXDATE"].append(get_period_item(p.get_start(), p.get_end()))
paul@784 428
paul@784 429
        return True
paul@784 430
paul@669 431
    def correct_object(self, tzid, permitted_values):
paul@661 432
paul@661 433
        "Correct the object's period details."
paul@661 434
paul@661 435
        corrected = set()
paul@661 436
        rdates = []
paul@661 437
paul@661 438
        for period in self.get_periods(tzid):
paul@661 439
            start = period.get_start()
paul@661 440
            end = period.get_end()
paul@669 441
            start_errors = check_permitted_values(start, permitted_values)
paul@669 442
            end_errors = check_permitted_values(end, permitted_values)
paul@627 443
paul@661 444
            if not (start_errors or end_errors):
paul@661 445
                if period.origin == "RDATE":
paul@661 446
                    rdates.append(period)
paul@661 447
                continue
paul@661 448
paul@661 449
            if start_errors:
paul@669 450
                start = correct_datetime(start, permitted_values)
paul@661 451
            if end_errors:
paul@669 452
                end = correct_datetime(end, permitted_values)
paul@661 453
            period = RecurringPeriod(start, end, period.tzid, period.origin, period.get_start_attr(), period.get_end_attr())
paul@661 454
paul@661 455
            if period.origin == "DTSTART":
paul@661 456
                self.set_period(period)
paul@661 457
                corrected.add("DTSTART")
paul@661 458
            elif period.origin == "RDATE":
paul@661 459
                rdates.append(period)
paul@661 460
                corrected.add("RDATE")
paul@661 461
paul@661 462
        if "RDATE" in corrected:
paul@661 463
            self.set_periods(rdates)
paul@661 464
paul@661 465
        return corrected
paul@627 466
paul@213 467
# Construction and serialisation.
paul@213 468
paul@213 469
def make_calendar(nodes, method=None):
paul@213 470
paul@213 471
    """
paul@213 472
    Return a complete calendar node wrapping the given 'nodes' and employing the
paul@213 473
    given 'method', if indicated.
paul@213 474
    """
paul@213 475
paul@213 476
    return ("VCALENDAR", {},
paul@213 477
            (method and [("METHOD", {}, method)] or []) +
paul@213 478
            [("VERSION", {}, "2.0")] +
paul@213 479
            nodes
paul@213 480
           )
paul@213 481
paul@327 482
def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None,
paul@562 483
                  attendee_attr=None, period=None):
paul@222 484
    
paul@222 485
    """
paul@222 486
    Return a calendar node defining the free/busy details described in the given
paul@292 487
    'freebusy' list, employing the given 'uid', for the given 'organiser' and
paul@292 488
    optional 'organiser_attr', with the optional 'attendee' providing recipient
paul@292 489
    details together with the optional 'attendee_attr'.
paul@327 490
paul@562 491
    The result will be constrained to the 'period' if specified.
paul@222 492
    """
paul@222 493
    
paul@222 494
    record = []
paul@222 495
    rwrite = record.append
paul@222 496
    
paul@292 497
    rwrite(("ORGANIZER", organiser_attr or {}, organiser))
paul@222 498
paul@222 499
    if attendee:
paul@292 500
        rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 
paul@222 501
paul@222 502
    rwrite(("UID", {}, uid))
paul@222 503
paul@222 504
    if freebusy:
paul@327 505
paul@327 506
        # Get a constrained view if start and end limits are specified.
paul@327 507
paul@563 508
        if period:
paul@563 509
            periods = period_overlaps(freebusy, period, True)
paul@563 510
        else:
paul@563 511
            periods = freebusy
paul@327 512
paul@327 513
        # Write the limits of the resource.
paul@327 514
paul@563 515
        if periods:
paul@563 516
            rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start_point())))
paul@563 517
            rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end_point())))
paul@563 518
        else:
paul@563 519
            rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_start_point())))
paul@563 520
            rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_end_point())))
paul@327 521
paul@458 522
        for p in periods:
paul@458 523
            if p.transp == "OPAQUE":
paul@529 524
                rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join(
paul@562 525
                    map(format_datetime, [p.get_start_point(), p.get_end_point()])
paul@529 526
                    )))
paul@222 527
paul@222 528
    return ("VFREEBUSY", {}, record)
paul@222 529
paul@213 530
def parse_object(f, encoding, objtype=None):
paul@213 531
paul@213 532
    """
paul@213 533
    Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is
paul@213 534
    given, only objects of that type will be returned. Otherwise, the root of
paul@213 535
    the content will be returned as a dictionary with a single key indicating
paul@213 536
    the object type.
paul@213 537
paul@213 538
    Return None if the content was not readable or suitable.
paul@213 539
    """
paul@213 540
paul@213 541
    try:
paul@213 542
        try:
paul@213 543
            doctype, attrs, elements = obj = parse(f, encoding=encoding)
paul@213 544
            if objtype and doctype == objtype:
paul@213 545
                return to_dict(obj)[objtype][0]
paul@213 546
            elif not objtype:
paul@213 547
                return to_dict(obj)
paul@213 548
        finally:
paul@213 549
            f.close()
paul@213 550
paul@213 551
    # NOTE: Handle parse errors properly.
paul@213 552
paul@213 553
    except (ParseError, ValueError):
paul@213 554
        pass
paul@213 555
paul@213 556
    return None
paul@213 557
paul@213 558
def to_part(method, calendar):
paul@213 559
paul@213 560
    """
paul@213 561
    Write using the given 'method', the 'calendar' details to a MIME
paul@213 562
    text/calendar part.
paul@213 563
    """
paul@213 564
paul@213 565
    encoding = "utf-8"
paul@213 566
    out = StringIO()
paul@213 567
    try:
paul@213 568
        to_stream(out, make_calendar(calendar, method), encoding)
paul@213 569
        part = MIMEText(out.getvalue(), "calendar", encoding)
paul@213 570
        part.set_param("method", method)
paul@213 571
        return part
paul@213 572
paul@213 573
    finally:
paul@213 574
        out.close()
paul@213 575
paul@213 576
def to_stream(out, fragment, encoding="utf-8"):
paul@213 577
    iterwrite(out, encoding=encoding).append(fragment)
paul@213 578
paul@213 579
# Structure access functions.
paul@213 580
paul@213 581
def get_items(d, name, all=True):
paul@213 582
paul@213 583
    """
paul@213 584
    Get all items from 'd' for the given 'name', returning single items if
paul@213 585
    'all' is specified and set to a false value and if only one value is
paul@213 586
    present for the name. Return None if no items are found for the name or if
paul@213 587
    many items are found but 'all' is set to a false value.
paul@213 588
    """
paul@213 589
paul@213 590
    if d.has_key(name):
paul@712 591
        items = [(value or None, attr) for value, attr in d[name]]
paul@213 592
        if all:
paul@462 593
            return items
paul@462 594
        elif len(items) == 1:
paul@462 595
            return items[0]
paul@213 596
        else:
paul@213 597
            return None
paul@213 598
    else:
paul@213 599
        return None
paul@213 600
paul@213 601
def get_item(d, name):
paul@213 602
    return get_items(d, name, False)
paul@213 603
paul@213 604
def get_value_map(d, name):
paul@213 605
paul@213 606
    """
paul@213 607
    Return a dictionary for all items in 'd' having the given 'name'. The
paul@213 608
    dictionary will map values for the name to any attributes or qualifiers
paul@213 609
    that may have been present.
paul@213 610
    """
paul@213 611
paul@213 612
    items = get_items(d, name)
paul@213 613
    if items:
paul@213 614
        return dict(items)
paul@213 615
    else:
paul@213 616
        return {}
paul@213 617
paul@462 618
def values_from_items(items):
paul@462 619
    return map(lambda x: x[0], items)
paul@462 620
paul@213 621
def get_values(d, name, all=True):
paul@213 622
    if d.has_key(name):
paul@462 623
        items = d[name]
paul@462 624
        if not all and len(items) == 1:
paul@462 625
            return items[0][0]
paul@213 626
        else:
paul@462 627
            return values_from_items(items)
paul@213 628
    else:
paul@213 629
        return None
paul@213 630
paul@213 631
def get_value(d, name):
paul@213 632
    return get_values(d, name, False)
paul@213 633
paul@417 634
def get_date_value_items(d, name, tzid=None):
paul@352 635
paul@352 636
    """
paul@389 637
    Obtain items from 'd' having the given 'name', where a single item yields
paul@389 638
    potentially many values. Return a list of tuples of the form (value,
paul@389 639
    attributes) where the attributes have been given for the property in 'd'.
paul@352 640
    """
paul@352 641
paul@403 642
    items = get_items(d, name)
paul@403 643
    if items:
paul@403 644
        all_items = []
paul@403 645
        for item in items:
paul@403 646
            values, attr = item
paul@417 647
            if not attr.has_key("TZID") and tzid:
paul@417 648
                attr["TZID"] = tzid
paul@403 649
            if not isinstance(values, list):
paul@403 650
                values = [values]
paul@403 651
            for value in values:
paul@403 652
                all_items.append((get_datetime(value, attr) or get_period(value, attr), attr))
paul@403 653
        return all_items
paul@352 654
    else:
paul@352 655
        return None
paul@352 656
paul@646 657
def get_period_values(d, name, tzid=None):
paul@630 658
paul@630 659
    """
paul@630 660
    Return period values from 'd' for the given property 'name', using 'tzid'
paul@646 661
    where specified to indicate the time zone.
paul@630 662
    """
paul@630 663
paul@630 664
    values = []
paul@630 665
    for value, attr in get_items(d, name) or []:
paul@630 666
        if not attr.has_key("TZID") and tzid:
paul@630 667
            attr["TZID"] = tzid
paul@630 668
        start, end = get_period(value, attr)
paul@646 669
        values.append(Period(start, end, tzid=tzid))
paul@630 670
    return values
paul@630 671
paul@506 672
def get_utc_datetime(d, name, date_tzid=None):
paul@506 673
paul@506 674
    """
paul@506 675
    Return the value provided by 'd' for 'name' as a datetime in the UTC zone
paul@506 676
    or as a date, converting any date to a datetime if 'date_tzid' is specified.
paul@720 677
    If no datetime or date is available, None is returned.
paul@506 678
    """
paul@506 679
paul@348 680
    t = get_datetime_item(d, name)
paul@348 681
    if not t:
paul@348 682
        return None
paul@348 683
    else:
paul@348 684
        dt, attr = t
paul@720 685
        return dt is not None and to_utc_datetime(dt, date_tzid) or None
paul@289 686
paul@289 687
def get_datetime_item(d, name):
paul@562 688
paul@562 689
    """
paul@562 690
    Return the value provided by 'd' for 'name' as a datetime or as a date,
paul@562 691
    together with the attributes describing it. Return None if no value exists
paul@562 692
    for 'name' in 'd'.
paul@562 693
    """
paul@562 694
paul@348 695
    t = get_item(d, name)
paul@348 696
    if not t:
paul@348 697
        return None
paul@348 698
    else:
paul@348 699
        value, attr = t
paul@613 700
        dt = get_datetime(value, attr)
paul@616 701
        tzid = get_datetime_tzid(dt)
paul@616 702
        if tzid:
paul@616 703
            attr["TZID"] = tzid
paul@613 704
        return dt, attr
paul@213 705
paul@528 706
# Conversion functions.
paul@528 707
paul@213 708
def get_addresses(values):
paul@213 709
    return [address for name, address in email.utils.getaddresses(values)]
paul@213 710
paul@213 711
def get_address(value):
paul@712 712
    if not value: return None
paul@333 713
    value = value.lower()
paul@333 714
    return value.startswith("mailto:") and value[7:] or value
paul@213 715
paul@213 716
def get_uri(value):
paul@712 717
    if not value: return None
paul@712 718
    return value.lower().startswith("mailto:") and value.lower() or \
paul@712 719
           ":" in value and value or \
paul@712 720
           "mailto:%s" % value.lower()
paul@213 721
paul@309 722
uri_value = get_uri
paul@309 723
paul@309 724
def uri_values(values):
paul@309 725
    return map(get_uri, values)
paul@309 726
paul@213 727
def uri_dict(d):
paul@213 728
    return dict([(get_uri(key), value) for key, value in d.items()])
paul@213 729
paul@213 730
def uri_item(item):
paul@213 731
    return get_uri(item[0]), item[1]
paul@213 732
paul@213 733
def uri_items(items):
paul@213 734
    return [(get_uri(value), attr) for value, attr in items]
paul@213 735
paul@220 736
# Operations on structure data.
paul@220 737
paul@682 738
def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, ignore_dtstamp):
paul@220 739
paul@220 740
    """
paul@220 741
    Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and
paul@682 742
    'new_dtstamp', and the 'ignore_dtstamp' indication, whether the object
paul@220 743
    providing the new information is really newer than the object providing the
paul@220 744
    old information.
paul@220 745
    """
paul@220 746
paul@220 747
    have_sequence = old_sequence is not None and new_sequence is not None
paul@220 748
    is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence)
paul@220 749
paul@220 750
    have_dtstamp = old_dtstamp and new_dtstamp
paul@220 751
    is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp
paul@220 752
paul@220 753
    is_old_sequence = have_sequence and (
paul@220 754
        int(new_sequence) < int(old_sequence) or
paul@220 755
        is_same_sequence and is_old_dtstamp
paul@220 756
        )
paul@220 757
paul@682 758
    return is_same_sequence and ignore_dtstamp or not is_old_sequence
paul@220 759
paul@630 760
def get_periods(obj, tzid, end=None, inclusive=False):
paul@256 761
paul@256 762
    """
paul@618 763
    Return periods for the given object 'obj', employing the given 'tzid' where
paul@618 764
    no time zone information is available (for whole day events, for example),
paul@630 765
    confining materialised periods to before the given 'end' datetime.
paul@618 766
paul@630 767
    If 'end' is omitted, only explicit recurrences and recurrences from
paul@630 768
    explicitly-terminated rules will be returned.
paul@630 769
paul@630 770
    If 'inclusive' is set to a true value, any period occurring at the 'end'
paul@630 771
    will be included.
paul@256 772
    """
paul@256 773
paul@318 774
    rrule = obj.get_value("RRULE")
paul@636 775
    parameters = rrule and get_parameters(rrule)
paul@318 776
paul@318 777
    # Use localised datetimes.
paul@318 778
paul@650 779
    (dtstart, dtstart_attr), (dtend, dtend_attr) = obj.get_main_period_items(tzid)
paul@650 780
    duration = dtend - dtstart
paul@256 781
paul@618 782
    # Attempt to get time zone details from the object, using the supplied zone
paul@618 783
    # only as a fallback.
paul@618 784
paul@638 785
    obj_tzid = obj.get_tzid()
paul@256 786
paul@352 787
    if not rrule:
paul@541 788
        periods = [RecurringPeriod(dtstart, dtend, tzid, "DTSTART", dtstart_attr, dtend_attr)]
paul@630 789
paul@636 790
    elif end or parameters and parameters.has_key("UNTIL") or parameters.has_key("COUNT"):
paul@630 791
paul@352 792
        # Recurrence rules create multiple instances to be checked.
paul@352 793
        # Conflicts may only be assessed within a period defined by policy
paul@352 794
        # for the agent, with instances outside that period being considered
paul@352 795
        # unchecked.
paul@352 796
paul@352 797
        selector = get_rule(dtstart, rrule)
paul@352 798
        periods = []
paul@352 799
paul@521 800
        until = parameters.get("UNTIL")
paul@521 801
        if until:
paul@650 802
            until_dt = to_timezone(get_datetime(until, dtstart_attr), obj_tzid)
paul@650 803
            end = end and min(until_dt, end) or until_dt
paul@521 804
            inclusive = True
paul@521 805
paul@630 806
        for recurrence_start in selector.materialise(dtstart, end, parameters.get("COUNT"), parameters.get("BYSETPOS"), inclusive):
paul@630 807
            create = len(recurrence_start) == 3 and date or datetime
paul@638 808
            recurrence_start = to_timezone(create(*recurrence_start), obj_tzid)
paul@630 809
            recurrence_end = recurrence_start + duration
paul@638 810
            periods.append(RecurringPeriod(recurrence_start, recurrence_end, tzid, "RRULE", dtstart_attr))
paul@352 811
paul@635 812
    else:
paul@635 813
        periods = []
paul@635 814
paul@352 815
    # Add recurrence dates.
paul@256 816
paul@494 817
    rdates = obj.get_date_value_items("RDATE", tzid)
paul@352 818
paul@352 819
    if rdates:
paul@494 820
        for rdate, rdate_attr in rdates:
paul@389 821
            if isinstance(rdate, tuple):
paul@541 822
                periods.append(RecurringPeriod(rdate[0], rdate[1], tzid, "RDATE", rdate_attr))
paul@389 823
            else:
paul@541 824
                periods.append(RecurringPeriod(rdate, rdate + duration, tzid, "RDATE", rdate_attr))
paul@424 825
paul@424 826
    # Return a sorted list of the periods.
paul@424 827
paul@542 828
    periods.sort()
paul@352 829
paul@352 830
    # Exclude exception dates.
paul@352 831
paul@638 832
    exdates = obj.get_date_value_items("EXDATE", tzid)
paul@256 833
paul@352 834
    if exdates:
paul@638 835
        for exdate, exdate_attr in exdates:
paul@389 836
            if isinstance(exdate, tuple):
paul@638 837
                period = RecurringPeriod(exdate[0], exdate[1], tzid, "EXDATE", exdate_attr)
paul@389 838
            else:
paul@638 839
                period = RecurringPeriod(exdate, exdate + duration, tzid, "EXDATE", exdate_attr)
paul@424 840
            i = bisect_left(periods, period)
paul@458 841
            while i < len(periods) and periods[i] == period:
paul@424 842
                del periods[i]
paul@256 843
paul@256 844
    return periods
paul@256 845
paul@606 846
def get_sender_identities(mapping):
paul@606 847
paul@606 848
    """
paul@606 849
    Return a mapping from actual senders to the identities for which they
paul@606 850
    have provided data, extracting this information from the given
paul@606 851
    'mapping'.
paul@606 852
    """
paul@606 853
paul@606 854
    senders = {}
paul@606 855
paul@606 856
    for value, attr in mapping.items():
paul@606 857
        sent_by = attr.get("SENT-BY")
paul@606 858
        if sent_by:
paul@606 859
            sender = get_uri(sent_by)
paul@606 860
        else:
paul@606 861
            sender = value
paul@606 862
paul@606 863
        if not senders.has_key(sender):
paul@606 864
            senders[sender] = []
paul@606 865
paul@606 866
        senders[sender].append(value)
paul@606 867
paul@606 868
    return senders
paul@606 869
paul@618 870
def get_window_end(tzid, days=100):
paul@606 871
paul@618 872
    """
paul@618 873
    Return a datetime in the time zone indicated by 'tzid' marking the end of a
paul@618 874
    window of the given number of 'days'.
paul@618 875
    """
paul@618 876
paul@618 877
    return to_timezone(datetime.now(), tzid) + timedelta(days)
paul@606 878
paul@213 879
# vim: tabstop=4 expandtab shiftwidth=4