imip-agent

Annotated imiptools/period.py

833:8ebca6ce3973
2015-10-14 Paul Boddie Fixed printable representations.
paul@48 1
#!/usr/bin/env python
paul@48 2
paul@146 3
"""
paul@146 4
Managing and presenting periods of time.
paul@146 5
paul@146 6
Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
paul@146 7
paul@146 8
This program is free software; you can redistribute it and/or modify it under
paul@146 9
the terms of the GNU General Public License as published by the Free Software
paul@146 10
Foundation; either version 3 of the License, or (at your option) any later
paul@146 11
version.
paul@146 12
paul@146 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@146 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@146 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@146 16
details.
paul@146 17
paul@146 18
You should have received a copy of the GNU General Public License along with
paul@146 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@146 20
"""
paul@146 21
paul@430 22
from bisect import bisect_left, bisect_right, insort_left
paul@529 23
from datetime import date, datetime, timedelta
paul@529 24
from imiptools.dates import format_datetime, get_datetime, \
paul@563 25
                            get_datetime_attributes, \
paul@563 26
                            get_recurrence_start, get_recurrence_start_point, \
paul@563 27
                            get_start_of_day, \
paul@563 28
                            get_tzid, \
paul@563 29
                            to_timezone, to_utc_datetime
paul@48 30
paul@646 31
class PeriodBase:
paul@458 32
paul@458 33
    "A basic period abstraction."
paul@458 34
paul@646 35
    def as_tuple(self):
paul@646 36
        return self.start, self.end
paul@646 37
paul@646 38
    def __hash__(self):
paul@646 39
        return hash((self.get_start(), self.get_end()))
paul@646 40
paul@646 41
    def __cmp__(self, other):
paul@646 42
paul@646 43
        "Return a comparison result against 'other' using points in time."
paul@646 44
paul@646 45
        if isinstance(other, PeriodBase):
paul@646 46
            return cmp(
paul@646 47
                (self.get_start_point(), self.get_end_point()),
paul@646 48
                (other.get_start_point(), other.get_end_point())
paul@646 49
                )
paul@646 50
        else:
paul@646 51
            return 1
paul@646 52
paul@646 53
    def get_key(self):
paul@646 54
        return self.get_start(), self.get_end()
paul@646 55
paul@646 56
    # Datetime and metadata methods.
paul@646 57
paul@646 58
    def get_start(self):
paul@646 59
        return self.start
paul@646 60
paul@646 61
    def get_end(self):
paul@646 62
        return self.end
paul@646 63
paul@646 64
    def get_start_attr(self):
paul@646 65
        return get_datetime_attributes(self.start, self.tzid)
paul@646 66
paul@646 67
    def get_end_attr(self):
paul@646 68
        return get_datetime_attributes(self.end, self.tzid)
paul@646 69
paul@646 70
    def get_start_item(self):
paul@646 71
        return self.get_start(), self.get_start_attr()
paul@646 72
paul@646 73
    def get_end_item(self):
paul@646 74
        return self.get_end(), self.get_end_attr()
paul@646 75
paul@646 76
    def get_start_point(self):
paul@646 77
        return self.start
paul@646 78
paul@646 79
    def get_end_point(self):
paul@646 80
        return self.end
paul@646 81
paul@659 82
    def get_duration(self):
paul@659 83
        return self.get_end_point() - self.get_start_point()
paul@659 84
paul@646 85
class Period(PeriodBase):
paul@646 86
paul@646 87
    "A simple period abstraction."
paul@646 88
paul@541 89
    def __init__(self, start, end, tzid=None, origin=None):
paul@541 90
paul@541 91
        """
paul@541 92
        Initialise a period with the given 'start' and 'end', having a
paul@541 93
        contextual 'tzid', if specified, and an indicated 'origin'.
paul@620 94
paul@620 95
        All metadata from the start and end points are derived from the supplied
paul@620 96
        dates/datetimes.
paul@541 97
        """
paul@541 98
paul@529 99
        self.start = isinstance(start, date) and start or get_datetime(start)
paul@529 100
        self.end = isinstance(end, date) and end or get_datetime(end)
paul@541 101
        self.tzid = tzid
paul@528 102
        self.origin = origin
paul@458 103
paul@458 104
    def as_tuple(self):
paul@541 105
        return self.start, self.end, self.tzid, self.origin
paul@458 106
paul@458 107
    def __repr__(self):
paul@630 108
        return "Period%r" % (self.as_tuple(),)
paul@458 109
paul@646 110
    # Datetime and metadata methods.
paul@528 111
paul@541 112
    def get_tzid(self):
paul@620 113
        return get_tzid(self.get_start_attr(), self.get_end_attr()) or self.tzid
paul@620 114
paul@557 115
    def get_start_point(self):
paul@557 116
        return to_utc_datetime(self.get_start(), self.get_tzid())
paul@557 117
paul@557 118
    def get_end_point(self):
paul@557 119
        return to_utc_datetime(self.get_end(), self.get_tzid())
paul@557 120
paul@633 121
    # Period and event recurrence logic.
paul@633 122
paul@633 123
    def is_replaced(self, recurrenceids):
paul@633 124
paul@633 125
        """
paul@633 126
        Return whether this period refers to one of the 'recurrenceids'.
paul@633 127
        The 'recurrenceids' should be normalised to UTC datetimes according to
paul@633 128
        time zone information provided by their objects or be floating dates or
paul@633 129
        datetimes requiring conversion using contextual time zone information.
paul@633 130
        """
paul@633 131
paul@633 132
        for recurrenceid in recurrenceids:
paul@647 133
            if self.is_affected(recurrenceid):
paul@633 134
                return recurrenceid
paul@633 135
        return None
paul@633 136
paul@633 137
    def is_affected(self, recurrenceid):
paul@633 138
paul@633 139
        """
paul@633 140
        Return whether this period refers to 'recurrenceid'. The 'recurrenceid'
paul@633 141
        should be normalised to UTC datetimes according to time zone information
paul@633 142
        provided by their objects. Otherwise, this period's contextual time zone
paul@633 143
        information is used to convert any date or floating datetime
paul@633 144
        representation to a point in time.
paul@633 145
        """
paul@633 146
paul@633 147
        if not recurrenceid:
paul@633 148
            return None
paul@633 149
        d = get_recurrence_start(recurrenceid)
paul@633 150
        dt = get_recurrence_start_point(recurrenceid, self.tzid)
paul@633 151
        if self.get_start() == d or self.get_start_point() == dt:
paul@633 152
            return recurrenceid
paul@633 153
        return None
paul@633 154
paul@646 155
class FreeBusyPeriod(PeriodBase):
paul@458 156
paul@458 157
    "A free/busy record abstraction."
paul@458 158
paul@710 159
    def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, summary=None, organiser=None, expires=None):
paul@529 160
paul@529 161
        """
paul@643 162
        Initialise a free/busy period with the given 'start' and 'end' points,
paul@646 163
        plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser'
paul@646 164
        details.
paul@710 165
paul@710 166
        An additional 'expires' parameter can be used to indicate an expiry
paul@710 167
        datetime in conjunction with free/busy offers made when countering
paul@710 168
        event proposals.
paul@529 169
        """
paul@529 170
paul@646 171
        self.start = isinstance(start, datetime) and start or get_datetime(start)
paul@646 172
        self.end = isinstance(end, datetime) and end or get_datetime(end)
paul@458 173
        self.uid = uid
paul@458 174
        self.transp = transp
paul@458 175
        self.recurrenceid = recurrenceid
paul@458 176
        self.summary = summary
paul@458 177
        self.organiser = organiser
paul@710 178
        self.expires = expires
paul@458 179
paul@563 180
    def as_tuple(self, strings_only=False):
paul@563 181
paul@563 182
        """
paul@563 183
        Return the initialisation parameter tuple, converting false value
paul@563 184
        parameters to strings if 'strings_only' is set to a true value.
paul@563 185
        """
paul@563 186
paul@563 187
        null = lambda x: (strings_only and [""] or [x])[0]
paul@563 188
        return (
paul@630 189
            strings_only and format_datetime(self.get_start_point()) or self.start,
paul@630 190
            strings_only and format_datetime(self.get_end_point()) or self.end,
paul@563 191
            self.uid or null(self.uid),
paul@563 192
            self.transp or strings_only and "OPAQUE" or None,
paul@563 193
            self.recurrenceid or null(self.recurrenceid),
paul@563 194
            self.summary or null(self.summary),
paul@710 195
            self.organiser or null(self.organiser),
paul@710 196
            self.expires or null(self.expires)
paul@563 197
            )
paul@458 198
paul@458 199
    def __cmp__(self, other):
paul@541 200
paul@541 201
        """
paul@541 202
        Compare this object to 'other', employing the uid if the periods
paul@541 203
        involved are the same.
paul@541 204
        """
paul@541 205
paul@646 206
        result = PeriodBase.__cmp__(self, other)
paul@541 207
        if result == 0 and isinstance(other, FreeBusyPeriod):
paul@653 208
            return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid))
paul@458 209
        else:
paul@541 210
            return result
paul@458 211
paul@458 212
    def get_key(self):
paul@541 213
        return self.uid, self.recurrenceid, self.get_start()
paul@458 214
paul@458 215
    def __repr__(self):
paul@630 216
        return "FreeBusyPeriod%r" % (self.as_tuple(),)
paul@458 217
paul@679 218
    # Period and event recurrence logic.
paul@679 219
paul@679 220
    def is_replaced(self, recurrences):
paul@679 221
paul@679 222
        """
paul@679 223
        Return whether this period refers to one of the 'recurrences'.
paul@679 224
        The 'recurrences' must be UTC datetimes corresponding to the start of
paul@679 225
        the period described by a recurrence.
paul@679 226
        """
paul@679 227
paul@679 228
        for recurrence in recurrences:
paul@679 229
            if self.is_affected(recurrence):
paul@679 230
                return True
paul@679 231
        return False
paul@679 232
paul@679 233
    def is_affected(self, recurrence):
paul@679 234
paul@679 235
        """
paul@679 236
        Return whether this period refers to 'recurrence'. The 'recurrence' must
paul@679 237
        be a UTC datetime corresponding to the start of the period described by
paul@679 238
        a recurrence.
paul@679 239
        """
paul@679 240
paul@679 241
        return recurrence and self.get_start_point() == recurrence
paul@679 242
paul@543 243
class RecurringPeriod(Period):
paul@543 244
    
paul@620 245
    """
paul@620 246
    A period with iCalendar metadata attributes and origin information from an
paul@620 247
    object.
paul@620 248
    """
paul@543 249
    
paul@543 250
    def __init__(self, start, end, tzid=None, origin=None, start_attr=None, end_attr=None):
paul@543 251
        Period.__init__(self, start, end, tzid, origin)
paul@543 252
        self.start_attr = start_attr
paul@543 253
        self.end_attr = end_attr
paul@543 254
paul@620 255
    def get_start_attr(self):
paul@620 256
        return self.start_attr
paul@543 257
paul@620 258
    def get_end_attr(self):
paul@620 259
        return self.end_attr
paul@543 260
paul@543 261
    def as_tuple(self):
paul@543 262
        return self.start, self.end, self.tzid, self.origin, self.start_attr, self.end_attr
paul@543 263
    
paul@543 264
    def __repr__(self):
paul@630 265
        return "RecurringPeriod%r" % (self.as_tuple(),)
paul@543 266
paul@529 267
# Time and period management.
paul@48 268
paul@343 269
def can_schedule(freebusy, periods, uid, recurrenceid):
paul@221 270
paul@221 271
    """
paul@221 272
    Return whether the 'freebusy' list can accommodate the given 'periods'
paul@343 273
    employing the specified 'uid' and 'recurrenceid'.
paul@221 274
    """
paul@221 275
paul@221 276
    for conflict in have_conflict(freebusy, periods, True):
paul@465 277
        if conflict.uid != uid or conflict.recurrenceid != recurrenceid:
paul@221 278
            return False
paul@221 279
paul@221 280
    return True
paul@221 281
paul@72 282
def have_conflict(freebusy, periods, get_conflicts=False):
paul@72 283
paul@72 284
    """
paul@72 285
    Return whether any period in 'freebusy' overlaps with the given 'periods',
paul@72 286
    returning a collection of such overlapping periods if 'get_conflicts' is
paul@72 287
    set to a true value.
paul@72 288
    """
paul@72 289
paul@458 290
    conflicts = set()
paul@458 291
    for p in periods:
paul@458 292
        overlapping = period_overlaps(freebusy, p, get_conflicts)
paul@74 293
        if overlapping:
paul@72 294
            if get_conflicts:
paul@458 295
                conflicts.update(overlapping)
paul@72 296
            else:
paul@72 297
                return True
paul@74 298
paul@72 299
    if get_conflicts:
paul@72 300
        return conflicts
paul@72 301
    else:
paul@72 302
        return False
paul@72 303
paul@48 304
def insert_period(freebusy, period):
paul@362 305
paul@362 306
    "Insert into 'freebusy' the given 'period'."
paul@362 307
paul@653 308
    i = bisect_left(freebusy, period)
paul@653 309
    if i == len(freebusy):
paul@653 310
        freebusy.append(period)
paul@653 311
    elif freebusy[i] != period:
paul@653 312
        freebusy.insert(i, period)
paul@48 313
paul@343 314
def remove_period(freebusy, uid, recurrenceid=None):
paul@362 315
paul@362 316
    """
paul@362 317
    Remove from 'freebusy' all periods associated with 'uid' and 'recurrenceid'
paul@362 318
    (which if omitted causes the "parent" object's periods to be referenced).
paul@362 319
    """
paul@362 320
paul@523 321
    removed = False
paul@48 322
    i = 0
paul@48 323
    while i < len(freebusy):
paul@458 324
        fb = freebusy[i]
paul@458 325
        if fb.uid == uid and fb.recurrenceid == recurrenceid:
paul@48 326
            del freebusy[i]
paul@523 327
            removed = True
paul@48 328
        else:
paul@48 329
            i += 1
paul@48 330
paul@523 331
    return removed
paul@523 332
paul@382 333
def remove_additional_periods(freebusy, uid, recurrenceids=None):
paul@381 334
paul@381 335
    """
paul@381 336
    Remove from 'freebusy' all periods associated with 'uid' having a
paul@381 337
    recurrence identifier indicating an additional or modified period.
paul@382 338
paul@382 339
    If 'recurrenceids' is specified, remove all periods associated with 'uid'
paul@382 340
    that do not have a recurrence identifier in the given list.
paul@381 341
    """
paul@381 342
paul@381 343
    i = 0
paul@381 344
    while i < len(freebusy):
paul@458 345
        fb = freebusy[i]
paul@458 346
        if fb.uid == uid and fb.recurrenceid and (
paul@382 347
            recurrenceids is None or
paul@458 348
            recurrenceids is not None and fb.recurrenceid not in recurrenceids
paul@382 349
            ):
paul@381 350
            del freebusy[i]
paul@381 351
        else:
paul@381 352
            i += 1
paul@381 353
paul@511 354
def remove_affected_period(freebusy, uid, start):
paul@362 355
paul@362 356
    """
paul@362 357
    Remove from 'freebusy' a period associated with 'uid' that provides an
paul@511 358
    occurrence starting at the given 'start' (provided by a recurrence
paul@511 359
    identifier, converted to a datetime). A recurrence identifier is used to
paul@511 360
    provide an alternative time period whilst also acting as a reference to the
paul@511 361
    originally-defined occurrence.
paul@362 362
    """
paul@362 363
paul@558 364
    search = Period(start, start)
paul@558 365
    found = bisect_left(freebusy, search)
paul@558 366
paul@376 367
    while found < len(freebusy):
paul@458 368
        fb = freebusy[found]
paul@376 369
paul@376 370
        # Stop looking if the start no longer matches the recurrence identifier.
paul@376 371
paul@558 372
        if fb.get_start_point() != search.get_start_point():
paul@376 373
            return
paul@376 374
paul@376 375
        # If the period belongs to the parent object, remove it and return.
paul@376 376
paul@458 377
        if not fb.recurrenceid and uid == fb.uid:
paul@361 378
            del freebusy[found]
paul@376 379
            break
paul@376 380
paul@376 381
        # Otherwise, keep looking for a matching period.
paul@376 382
paul@376 383
        found += 1
paul@361 384
paul@658 385
def periods_from(freebusy, period):
paul@658 386
paul@658 387
    "Return the entries in 'freebusy' at or after 'period'."
paul@658 388
paul@658 389
    first = bisect_left(freebusy, period)
paul@658 390
    return freebusy[first:]
paul@658 391
paul@658 392
def periods_until(freebusy, period):
paul@658 393
paul@658 394
    "Return the entries in 'freebusy' before 'period'."
paul@658 395
paul@658 396
    last = bisect_right(freebusy, Period(period.get_end(), period.get_end(), period.get_tzid()))
paul@658 397
    return freebusy[:last]
paul@658 398
paul@327 399
def get_overlapping(freebusy, period):
paul@327 400
paul@327 401
    """
paul@430 402
    Return the entries in 'freebusy' providing periods overlapping with
paul@327 403
    'period'.
paul@327 404
    """
paul@327 405
paul@430 406
    # Find the range of periods potentially overlapping the period in the
paul@430 407
    # free/busy collection.
paul@327 408
paul@658 409
    startpoints = periods_until(freebusy, period)
paul@327 410
paul@430 411
    # Find the range of periods potentially overlapping the period in a version
paul@430 412
    # of the free/busy collection sorted according to end datetimes.
paul@327 413
paul@557 414
    endpoints = [(fb.get_end_point(), fb.get_start_point(), fb) for fb in startpoints]
paul@430 415
    endpoints.sort()
paul@557 416
    first = bisect_left(endpoints, (period.get_start_point(),))
paul@430 417
    endpoints = endpoints[first:]
paul@327 418
paul@430 419
    overlapping = set()
paul@327 420
paul@458 421
    for end, start, fb in endpoints:
paul@557 422
        if end > period.get_start_point() and start < period.get_end_point():
paul@458 423
            overlapping.add(fb)
paul@327 424
paul@430 425
    overlapping = list(overlapping)
paul@430 426
    overlapping.sort()
paul@430 427
    return overlapping
paul@327 428
paul@74 429
def period_overlaps(freebusy, period, get_periods=False):
paul@72 430
paul@72 431
    """
paul@74 432
    Return whether any period in 'freebusy' overlaps with the given 'period',
paul@74 433
    returning a collection of overlapping periods if 'get_periods' is set to a
paul@74 434
    true value.
paul@72 435
    """
paul@72 436
paul@430 437
    overlapping = get_overlapping(freebusy, period)
paul@74 438
paul@74 439
    if get_periods:
paul@430 440
        return overlapping
paul@74 441
    else:
paul@430 442
        return len(overlapping) != 0
paul@327 443
paul@327 444
def remove_overlapping(freebusy, period):
paul@327 445
paul@327 446
    "Remove from 'freebusy' all periods overlapping with 'period'."
paul@327 447
paul@437 448
    overlapping = get_overlapping(freebusy, period)
paul@327 449
paul@437 450
    if overlapping:
paul@437 451
        for fb in overlapping:
paul@437 452
            freebusy.remove(fb)
paul@327 453
paul@327 454
def replace_overlapping(freebusy, period, replacements):
paul@327 455
paul@327 456
    """
paul@327 457
    Replace existing periods in 'freebusy' within the given 'period', using the
paul@327 458
    given 'replacements'.
paul@327 459
    """
paul@327 460
paul@327 461
    remove_overlapping(freebusy, period)
paul@327 462
    for replacement in replacements:
paul@327 463
        insert_period(freebusy, replacement)
paul@48 464
paul@658 465
def coalesce_freebusy(freebusy):
paul@658 466
paul@658 467
    "Coalesce the periods in 'freebusy'."
paul@658 468
paul@658 469
    if not freebusy:
paul@658 470
        return freebusy
paul@658 471
paul@658 472
    fb = []
paul@658 473
    start = freebusy[0].get_start_point()
paul@658 474
    end = freebusy[0].get_end_point()
paul@658 475
paul@658 476
    for period in freebusy[1:]:
paul@658 477
        if period.get_start_point() > end:
paul@658 478
            fb.append(FreeBusyPeriod(start, end))
paul@658 479
            start = period.get_start_point()
paul@658 480
            end = period.get_end_point()
paul@658 481
        else:
paul@658 482
            end = max(end, period.get_end_point())
paul@658 483
paul@658 484
    fb.append(FreeBusyPeriod(start, end))
paul@658 485
    return fb
paul@658 486
paul@658 487
def invert_freebusy(freebusy):
paul@658 488
paul@658 489
    "Return the free periods from 'freebusy'."
paul@658 490
paul@658 491
    if not freebusy:
paul@658 492
        return None
paul@658 493
paul@658 494
    fb = coalesce_freebusy(freebusy)
paul@658 495
    free = []
paul@658 496
    start = fb[0].get_end_point()
paul@658 497
paul@658 498
    for period in fb[1:]:
paul@658 499
        free.append(FreeBusyPeriod(start, period.get_start_point()))
paul@658 500
        start = period.get_end_point()
paul@658 501
paul@658 502
    return free
paul@658 503
paul@529 504
# Period layout.
paul@204 505
paul@529 506
def get_scale(periods, tzid):
paul@113 507
paul@113 508
    """
paul@458 509
    Return an ordered time scale from the given list of 'periods'.
paul@153 510
paul@162 511
    The given 'tzid' is used to make sure that the times are defined according
paul@162 512
    to the chosen time zone.
paul@162 513
paul@162 514
    The returned scale is a mapping from time to (starting, ending) tuples,
paul@458 515
    where starting and ending are collections of periods.
paul@113 516
    """
paul@113 517
paul@113 518
    scale = {}
paul@113 519
paul@458 520
    for p in periods:
paul@113 521
paul@113 522
        # Add a point and this event to the starting list.
paul@113 523
paul@536 524
        start = to_timezone(p.get_start(), tzid)
paul@536 525
        if not scale.has_key(start):
paul@536 526
            scale[start] = [], []
paul@536 527
        scale[start][0].append(p)
paul@113 528
paul@113 529
        # Add a point and this event to the ending list.
paul@113 530
paul@536 531
        end = to_timezone(p.get_end(), tzid)
paul@536 532
        if not scale.has_key(end):
paul@536 533
            scale[end] = [], []
paul@536 534
        scale[end][1].append(p)
paul@113 535
paul@113 536
    return scale
paul@113 537
paul@455 538
class Point:
paul@455 539
paul@455 540
    "A qualified point in time."
paul@455 541
paul@455 542
    PRINCIPAL, REPEATED = 0, 1
paul@455 543
paul@455 544
    def __init__(self, point, indicator=None):
paul@455 545
        self.point = point
paul@455 546
        self.indicator = indicator or self.PRINCIPAL
paul@455 547
paul@455 548
    def __hash__(self):
paul@455 549
        return hash((self.point, self.indicator))
paul@455 550
paul@455 551
    def __cmp__(self, other):
paul@455 552
        if isinstance(other, Point):
paul@455 553
            return cmp((self.point, self.indicator), (other.point, other.indicator))
paul@455 554
        elif isinstance(other, datetime):
paul@455 555
            return cmp(self.point, other)
paul@455 556
        else:
paul@455 557
            return 1
paul@455 558
paul@455 559
    def __eq__(self, other):
paul@455 560
        return self.__cmp__(other) == 0
paul@455 561
paul@455 562
    def __ne__(self, other):
paul@455 563
        return not self == other
paul@455 564
paul@455 565
    def __lt__(self, other):
paul@455 566
        return self.__cmp__(other) < 0
paul@455 567
paul@455 568
    def __le__(self, other):
paul@455 569
        return self.__cmp__(other) <= 0
paul@455 570
paul@455 571
    def __gt__(self, other):
paul@455 572
        return not self <= other
paul@455 573
paul@455 574
    def __ge__(self, other):
paul@455 575
        return not self < other
paul@455 576
paul@455 577
    def __repr__(self):
paul@455 578
        return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL")
paul@452 579
paul@162 580
def get_slots(scale):
paul@113 581
paul@113 582
    """
paul@162 583
    Return an ordered list of time slots from the given 'scale'.
paul@113 584
paul@452 585
    Each slot is a tuple containing details of a point in time for the start of
paul@458 586
    the slot, together with a list of parallel event periods.
paul@452 587
paul@455 588
    Each point in time is described as a Point representing the actual point in
paul@455 589
    time together with an indicator of the nature of the point in time (as a
paul@455 590
    principal point in a time scale or as a repeated point used to terminate
paul@455 591
    events occurring for an instant in time).
paul@113 592
    """
paul@113 593
paul@113 594
    slots = []
paul@113 595
    active = []
paul@113 596
paul@162 597
    points = scale.items()
paul@162 598
    points.sort()
paul@162 599
paul@162 600
    for point, (starting, ending) in points:
paul@449 601
        ending = set(ending)
paul@449 602
        instants = ending.intersection(starting)
paul@113 603
paul@113 604
        # Discard all active events ending at or before this start time.
paul@161 605
        # Free up the position in the active list.
paul@113 606
paul@449 607
        for t in ending.difference(instants):
paul@113 608
            i = active.index(t)
paul@113 609
            active[i] = None
paul@113 610
paul@161 611
        # For each event starting at the current point, fill any newly-vacated
paul@161 612
        # position or add to the end of the active list.
paul@161 613
paul@113 614
        for t in starting:
paul@113 615
            try:
paul@113 616
                i = active.index(None)
paul@113 617
                active[i] = t
paul@113 618
            except ValueError:
paul@113 619
                active.append(t)
paul@113 620
paul@161 621
        # Discard vacant positions from the end of the active list.
paul@161 622
paul@113 623
        while active and active[-1] is None:
paul@113 624
            active.pop()
paul@113 625
paul@452 626
        # Add an entry for the time point before "instants".
paul@452 627
paul@455 628
        slots.append((Point(point), active[:]))
paul@113 629
paul@449 630
        # Discard events ending at the same time as they began.
paul@449 631
paul@449 632
        if instants:
paul@449 633
            for t in instants:
paul@449 634
                i = active.index(t)
paul@449 635
                active[i] = None
paul@449 636
paul@449 637
            # Discard vacant positions from the end of the active list.
paul@449 638
paul@449 639
            while active and active[-1] is None:
paul@449 640
                active.pop()
paul@449 641
paul@452 642
            # Add another entry for the time point after "instants".
paul@449 643
paul@455 644
            slots.append((Point(point, Point.REPEATED), active[:]))
paul@449 645
paul@113 646
    return slots
paul@113 647
paul@244 648
def add_day_start_points(slots, tzid):
paul@153 649
paul@153 650
    """
paul@162 651
    Introduce into the 'slots' any day start points required by multi-day
paul@244 652
    periods. The 'tzid' is required to make sure that appropriate time zones
paul@244 653
    are chosen and not necessarily those provided by the existing time points.
paul@153 654
    """
paul@153 655
paul@162 656
    new_slots = []
paul@153 657
    current_date = None
paul@200 658
    previously_active = []
paul@153 659
paul@455 660
    for point, active in slots:
paul@455 661
        start_of_day = get_start_of_day(point.point, tzid)
paul@455 662
        this_date = point.point.date()
paul@153 663
paul@198 664
        # For each new day, add a slot for the start of the day where periods
paul@198 665
        # are active and where no such slot already exists.
paul@153 666
paul@153 667
        if this_date != current_date:
paul@414 668
paul@414 669
            # Fill in days where events remain active.
paul@414 670
paul@414 671
            if current_date:
paul@414 672
                current_date += timedelta(1)
paul@414 673
                while current_date < this_date:
paul@455 674
                    new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active))
paul@414 675
                    current_date += timedelta(1)
paul@414 676
            else:
paul@414 677
                current_date = this_date
paul@153 678
paul@153 679
            # Add any continuing periods.
paul@153 680
paul@455 681
            if point.point != start_of_day:
paul@455 682
                new_slots.append((Point(start_of_day), previously_active))
paul@153 683
paul@153 684
        # Add the currently active periods at this point in time.
paul@153 685
paul@153 686
        previously_active = active
paul@153 687
paul@162 688
    for t in new_slots:
paul@162 689
        insort_left(slots, t)
paul@162 690
paul@162 691
def add_slots(slots, points):
paul@162 692
paul@162 693
    """
paul@162 694
    Introduce into the 'slots' entries for those in 'points' that are not
paul@170 695
    already present, propagating active periods from time points preceding
paul@170 696
    those added.
paul@162 697
    """
paul@162 698
paul@162 699
    new_slots = []
paul@162 700
paul@162 701
    for point in points:
paul@452 702
        i = bisect_left(slots, (point,)) # slots is [(point, active)...]
paul@162 703
        if i < len(slots) and slots[i][0] == point:
paul@162 704
            continue
paul@162 705
paul@170 706
        new_slots.append((point, i > 0 and slots[i-1][1] or []))
paul@162 707
paul@162 708
    for t in new_slots:
paul@162 709
        insort_left(slots, t)
paul@162 710
paul@162 711
def partition_by_day(slots):
paul@162 712
paul@162 713
    """
paul@162 714
    Return a mapping from dates to time points provided by 'slots'.
paul@162 715
    """
paul@162 716
paul@162 717
    d = {}
paul@162 718
paul@455 719
    for point, value in slots:
paul@455 720
        day = point.point.date()
paul@162 721
        if not d.has_key(day):
paul@162 722
            d[day] = []
paul@455 723
        d[day].append((point, value))
paul@162 724
paul@162 725
    return d
paul@153 726
paul@283 727
def add_empty_days(days, tzid):
paul@279 728
paul@279 729
    "Add empty days to 'days' between busy days."
paul@279 730
paul@279 731
    last_day = None
paul@279 732
    all_days = days.keys()
paul@279 733
    all_days.sort()
paul@279 734
paul@279 735
    for day in all_days:
paul@279 736
        if last_day:
paul@279 737
            empty_day = last_day + timedelta(1)
paul@279 738
            while empty_day < day:
paul@455 739
                days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]
paul@279 740
                empty_day += timedelta(1)
paul@279 741
        last_day = day 
paul@279 742
paul@114 743
def get_spans(slots):
paul@114 744
paul@533 745
    "Inspect the given 'slots', returning a mapping of period keys to spans."
paul@114 746
paul@455 747
    points = [point for point, active in slots]
paul@114 748
    spans = {}
paul@114 749
paul@449 750
    for _point, active in slots:
paul@458 751
        for p in active:
paul@458 752
            if p:
paul@458 753
                key = p.get_key()
paul@529 754
                start_slot = bisect_left(points, p.get_start())
paul@529 755
                end_slot = bisect_left(points, p.get_end())
paul@185 756
                spans[key] = end_slot - start_slot
paul@114 757
paul@114 758
    return spans
paul@114 759
paul@740 760
def update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None):
paul@250 761
paul@250 762
    """
paul@395 763
    Update the free/busy details with the given 'periods', 'transp' setting,
paul@395 764
    'uid' plus 'recurrenceid' and 'summary' and 'organiser' details.
paul@740 765
paul@740 766
    An optional 'expires' datetime string indicates the expiry time of any
paul@740 767
    free/busy offer.
paul@250 768
    """
paul@250 769
paul@343 770
    remove_period(freebusy, uid, recurrenceid)
paul@250 771
paul@458 772
    for p in periods:
paul@740 773
        insert_period(freebusy, FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires))
paul@250 774
paul@48 775
# vim: tabstop=4 expandtab shiftwidth=4