1 #!/usr/bin/env python 2 3 """ 4 Managing and presenting periods of time. 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, bisect_right, insort_left 23 from datetime import date, datetime, timedelta 24 from imiptools.dates import check_permitted_values, correct_datetime, \ 25 format_datetime, get_datetime, \ 26 get_datetime_attributes, \ 27 get_recurrence_start, get_recurrence_start_point, \ 28 get_start_of_day, \ 29 get_tzid, \ 30 to_timezone, to_utc_datetime 31 32 def ifnone(x, y): 33 if x is None: return y 34 else: return x 35 36 class Comparable: 37 38 "A date/datetime wrapper that allows comparisons with other types." 39 40 def __init__(self, dt): 41 self.dt = dt 42 43 def __cmp__(self, other): 44 dt = None 45 odt = None 46 47 # Find any dates/datetimes. 48 49 if isinstance(self.dt, date): 50 dt = self.dt 51 if isinstance(other, date): 52 odt = other 53 elif isinstance(other, Comparable): 54 if isinstance(other.dt, date): 55 odt = other.dt 56 else: 57 other = other.dt 58 59 if dt and odt: 60 return cmp(dt, odt) 61 elif dt: 62 return other.__rcmp__(dt) 63 elif odt: 64 return self.dt.__cmp__(odt) 65 else: 66 return self.dt.__cmp__(other) 67 68 class PointInTime: 69 70 "A base class for special values." 71 72 pass 73 74 class StartOfTime(PointInTime): 75 76 "A special value that compares earlier than other values." 77 78 def __cmp__(self, other): 79 if isinstance(other, StartOfTime): 80 return 0 81 else: 82 return -1 83 84 def __rcmp__(self, other): 85 return -self.__cmp__(other) 86 87 def __nonzero__(self): 88 return False 89 90 def __hash__(self): 91 return 0 92 93 class EndOfTime(PointInTime): 94 95 "A special value that compares later than other values." 96 97 def __cmp__(self, other): 98 if isinstance(other, EndOfTime): 99 return 0 100 else: 101 return 1 102 103 def __rcmp__(self, other): 104 return -self.__cmp__(other) 105 106 def __nonzero__(self): 107 return False 108 109 def __hash__(self): 110 return 0 111 112 class Endless: 113 114 "A special value indicating an endless period." 115 116 def __cmp__(self, other): 117 if isinstance(other, Endless): 118 return 0 119 else: 120 return 1 121 122 def __rcmp__(self, other): 123 return -self.__cmp__(other) 124 125 def __nonzero__(self): 126 return True 127 128 class PeriodBase: 129 130 "A basic period abstraction." 131 132 def __init__(self, start, end): 133 if isinstance(start, (date, PointInTime)): 134 self.start = start 135 else: 136 self.start = get_datetime(start) or StartOfTime() 137 if isinstance(end, (date, PointInTime)): 138 self.end = end 139 else: 140 self.end = get_datetime(end) or EndOfTime() 141 142 def as_tuple(self): 143 return self.start, self.end 144 145 def __hash__(self): 146 return hash((self.get_start(), self.get_end())) 147 148 def __cmp__(self, other): 149 150 "Return a comparison result against 'other' using points in time." 151 152 if isinstance(other, PeriodBase): 153 return cmp( 154 (Comparable(ifnone(self.get_start_point(), StartOfTime())), Comparable(ifnone(self.get_end_point(), EndOfTime()))), 155 (Comparable(ifnone(other.get_start_point(), StartOfTime())), Comparable(ifnone(other.get_end_point(), EndOfTime()))) 156 ) 157 else: 158 return 1 159 160 def overlaps(self, other): 161 return Comparable(ifnone(self.get_end_point(), EndOfTime())) > Comparable(ifnone(other.get_start_point(), StartOfTime())) and \ 162 Comparable(ifnone(self.get_start_point(), StartOfTime())) < Comparable(ifnone(other.get_end_point(), EndOfTime())) 163 164 def within(self, other): 165 return Comparable(ifnone(self.get_start_point(), StartOfTime())) >= Comparable(ifnone(other.get_start_point(), StartOfTime())) and \ 166 Comparable(ifnone(self.get_end_point(), EndOfTime())) <= Comparable(ifnone(other.get_end_point(), EndOfTime())) 167 168 def common(self, other): 169 start = max(Comparable(ifnone(self.get_start_point(), StartOfTime())), Comparable(ifnone(other.get_start_point(), StartOfTime()))) 170 end = min(Comparable(ifnone(self.get_end_point(), EndOfTime())), Comparable(ifnone(other.get_end_point(), EndOfTime()))) 171 if start <= end: 172 return self.make_corrected(start.dt, end.dt) 173 else: 174 return None 175 176 def get_key(self): 177 return self.get_start(), self.get_end() 178 179 # Datetime and metadata methods. 180 181 def get_start(self): 182 return self.start 183 184 def get_end(self): 185 return self.end 186 187 def get_start_attr(self): 188 return get_datetime_attributes(self.start, self.tzid) 189 190 def get_end_attr(self): 191 return get_datetime_attributes(self.end, self.tzid) 192 193 def get_start_item(self): 194 return self.get_start(), self.get_start_attr() 195 196 def get_end_item(self): 197 return self.get_end(), self.get_end_attr() 198 199 def get_start_point(self): 200 return self.start 201 202 def get_end_point(self): 203 return self.end 204 205 def get_duration(self): 206 start = self.get_start_point() 207 end = self.get_end_point() 208 if start and end: 209 return end - start 210 else: 211 return Endless() 212 213 class Period(PeriodBase): 214 215 "A simple period abstraction." 216 217 def __init__(self, start, end, tzid=None, origin=None): 218 219 """ 220 Initialise a period with the given 'start' and 'end', having a 221 contextual 'tzid', if specified, and an indicated 'origin'. 222 223 All metadata from the start and end points are derived from the supplied 224 dates/datetimes. 225 """ 226 227 PeriodBase.__init__(self, start, end) 228 self.tzid = tzid 229 self.origin = origin 230 231 def as_tuple(self): 232 return self.start, self.end, self.tzid, self.origin 233 234 def __repr__(self): 235 return "Period%r" % (self.as_tuple(),) 236 237 # Datetime and metadata methods. 238 239 def get_tzid(self): 240 return get_tzid(self.get_start_attr(), self.get_end_attr()) or self.tzid 241 242 def get_start_point(self): 243 start = self.get_start() 244 if isinstance(start, PointInTime): return start 245 else: return to_utc_datetime(start, self.get_tzid()) 246 247 def get_end_point(self): 248 end = self.get_end() 249 if isinstance(end, PointInTime): return end 250 else: return to_utc_datetime(end, self.get_tzid()) 251 252 # Period and event recurrence logic. 253 254 def is_replaced(self, recurrenceids): 255 256 """ 257 Return whether this period refers to one of the 'recurrenceids'. 258 The 'recurrenceids' should be normalised to UTC datetimes according to 259 time zone information provided by their objects or be floating dates or 260 datetimes requiring conversion using contextual time zone information. 261 """ 262 263 for recurrenceid in recurrenceids: 264 if self.is_affected(recurrenceid): 265 return recurrenceid 266 return None 267 268 def is_affected(self, recurrenceid): 269 270 """ 271 Return whether this period refers to 'recurrenceid'. The 'recurrenceid' 272 should be normalised to UTC datetimes according to time zone information 273 provided by their objects. Otherwise, this period's contextual time zone 274 information is used to convert any date or floating datetime 275 representation to a point in time. 276 """ 277 278 if not recurrenceid: 279 return None 280 d = get_recurrence_start(recurrenceid) 281 dt = get_recurrence_start_point(recurrenceid, self.tzid) 282 if self.get_start() == d or self.get_start_point() == dt: 283 return recurrenceid 284 return None 285 286 # Value correction methods. 287 288 def with_duration(self, duration): 289 290 """ 291 Return a version of this period with the same start point but with the 292 given 'duration'. 293 """ 294 295 return self.make_corrected(self.get_start(), self.get_start() + duration) 296 297 def check_permitted(self, permitted_values): 298 299 "Check the period against the given 'permitted_values'." 300 301 start = self.get_start() 302 end = self.get_end() 303 start_errors = check_permitted_values(start, permitted_values) 304 end_errors = check_permitted_values(end, permitted_values) 305 306 if not (start_errors or end_errors): 307 return None 308 309 return start_errors, end_errors 310 311 def get_corrected(self, permitted_values): 312 313 "Return a corrected version of this period." 314 315 errors = self.check_permitted(permitted_values) 316 317 if not errors: 318 return self 319 320 start_errors, end_errors = errors 321 322 if start_errors: 323 start = correct_datetime(start, permitted_values) 324 if end_errors: 325 end = correct_datetime(end, permitted_values) 326 327 return self.make_corrected(start, end) 328 329 def make_corrected(self, start, end): 330 return self.__class__(start, end, self.tzid, self.origin) 331 332 class FreeBusyPeriod(PeriodBase): 333 334 "A free/busy record abstraction." 335 336 def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, summary=None, organiser=None, expires=None): 337 338 """ 339 Initialise a free/busy period with the given 'start' and 'end' points, 340 plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' 341 details. 342 343 An additional 'expires' parameter can be used to indicate an expiry 344 datetime in conjunction with free/busy offers made when countering 345 event proposals. 346 """ 347 348 PeriodBase.__init__(self, start, end) 349 self.uid = uid 350 self.transp = transp 351 self.recurrenceid = recurrenceid 352 self.summary = summary 353 self.organiser = organiser 354 self.expires = expires 355 356 def as_tuple(self, strings_only=False): 357 358 """ 359 Return the initialisation parameter tuple, converting false value 360 parameters to strings if 'strings_only' is set to a true value. 361 """ 362 363 null = lambda x: (strings_only and [""] or [x])[0] 364 return ( 365 strings_only and format_datetime(self.get_start_point()) or self.start, 366 strings_only and format_datetime(self.get_end_point()) or self.end, 367 self.uid or null(self.uid), 368 self.transp or strings_only and "OPAQUE" or None, 369 self.recurrenceid or null(self.recurrenceid), 370 self.summary or null(self.summary), 371 self.organiser or null(self.organiser), 372 self.expires or null(self.expires) 373 ) 374 375 def __cmp__(self, other): 376 377 """ 378 Compare this object to 'other', employing the uid if the periods 379 involved are the same. 380 """ 381 382 result = PeriodBase.__cmp__(self, other) 383 if result == 0 and isinstance(other, FreeBusyPeriod): 384 return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid)) 385 else: 386 return result 387 388 def get_key(self): 389 return self.uid, self.recurrenceid, self.get_start() 390 391 def __repr__(self): 392 return "FreeBusyPeriod%r" % (self.as_tuple(),) 393 394 def get_tzid(self): 395 return "UTC" 396 397 # Period and event recurrence logic. 398 399 def is_replaced(self, recurrences): 400 401 """ 402 Return whether this period refers to one of the 'recurrences'. 403 The 'recurrences' must be UTC datetimes corresponding to the start of 404 the period described by a recurrence. 405 """ 406 407 for recurrence in recurrences: 408 if self.is_affected(recurrence): 409 return True 410 return False 411 412 def is_affected(self, recurrence): 413 414 """ 415 Return whether this period refers to 'recurrence'. The 'recurrence' must 416 be a UTC datetime corresponding to the start of the period described by 417 a recurrence. 418 """ 419 420 return recurrence and self.get_start_point() == recurrence 421 422 # Value correction methods. 423 424 def make_corrected(self, start, end): 425 return self.__class__(start, end) 426 427 class RecurringPeriod(Period): 428 429 """ 430 A period with iCalendar metadata attributes and origin information from an 431 object. 432 """ 433 434 def __init__(self, start, end, tzid=None, origin=None, start_attr=None, end_attr=None): 435 Period.__init__(self, start, end, tzid, origin) 436 self.start_attr = start_attr 437 self.end_attr = end_attr 438 439 def get_start_attr(self): 440 return self.start_attr 441 442 def get_end_attr(self): 443 return self.end_attr 444 445 def as_tuple(self): 446 return self.start, self.end, self.tzid, self.origin, self.start_attr, self.end_attr 447 448 def __repr__(self): 449 return "RecurringPeriod%r" % (self.as_tuple(),) 450 451 def make_corrected(self, start, end): 452 return self.__class__(start, end, self.tzid, self.origin, self.get_start_attr(), self.get_end_attr()) 453 454 # Time and period management. 455 456 def can_schedule(freebusy, periods, uid, recurrenceid): 457 458 """ 459 Return whether the 'freebusy' list can accommodate the given 'periods' 460 employing the specified 'uid' and 'recurrenceid'. 461 """ 462 463 for conflict in have_conflict(freebusy, periods, True): 464 if conflict.uid != uid or conflict.recurrenceid != recurrenceid: 465 return False 466 467 return True 468 469 def have_conflict(freebusy, periods, get_conflicts=False): 470 471 """ 472 Return whether any period in 'freebusy' overlaps with the given 'periods', 473 returning a collection of such overlapping periods if 'get_conflicts' is 474 set to a true value. 475 """ 476 477 conflicts = set() 478 for p in periods: 479 overlapping = period_overlaps(freebusy, p, get_conflicts) 480 if overlapping: 481 if get_conflicts: 482 conflicts.update(overlapping) 483 else: 484 return True 485 486 if get_conflicts: 487 return conflicts 488 else: 489 return False 490 491 def insert_period(freebusy, period): 492 493 "Insert into 'freebusy' the given 'period'." 494 495 i = bisect_left(freebusy, period) 496 if i == len(freebusy): 497 freebusy.append(period) 498 elif freebusy[i] != period: 499 freebusy.insert(i, period) 500 501 def remove_period(freebusy, uid, recurrenceid=None): 502 503 """ 504 Remove from 'freebusy' all periods associated with 'uid' and 'recurrenceid' 505 (which if omitted causes the "parent" object's periods to be referenced). 506 """ 507 508 removed = False 509 i = 0 510 while i < len(freebusy): 511 fb = freebusy[i] 512 if fb.uid == uid and fb.recurrenceid == recurrenceid: 513 del freebusy[i] 514 removed = True 515 else: 516 i += 1 517 518 return removed 519 520 def remove_additional_periods(freebusy, uid, recurrenceids=None): 521 522 """ 523 Remove from 'freebusy' all periods associated with 'uid' having a 524 recurrence identifier indicating an additional or modified period. 525 526 If 'recurrenceids' is specified, remove all periods associated with 'uid' 527 that do not have a recurrence identifier in the given list. 528 """ 529 530 i = 0 531 while i < len(freebusy): 532 fb = freebusy[i] 533 if fb.uid == uid and fb.recurrenceid and ( 534 recurrenceids is None or 535 recurrenceids is not None and fb.recurrenceid not in recurrenceids 536 ): 537 del freebusy[i] 538 else: 539 i += 1 540 541 def remove_affected_period(freebusy, uid, start): 542 543 """ 544 Remove from 'freebusy' a period associated with 'uid' that provides an 545 occurrence starting at the given 'start' (provided by a recurrence 546 identifier, converted to a datetime). A recurrence identifier is used to 547 provide an alternative time period whilst also acting as a reference to the 548 originally-defined occurrence. 549 """ 550 551 search = Period(start, start) 552 found = bisect_left(freebusy, search) 553 554 while found < len(freebusy): 555 fb = freebusy[found] 556 557 # Stop looking if the start no longer matches the recurrence identifier. 558 559 if fb.get_start_point() != search.get_start_point(): 560 return 561 562 # If the period belongs to the parent object, remove it and return. 563 564 if not fb.recurrenceid and uid == fb.uid: 565 del freebusy[found] 566 break 567 568 # Otherwise, keep looking for a matching period. 569 570 found += 1 571 572 def periods_from(freebusy, period): 573 574 "Return the entries in 'freebusy' at or after 'period'." 575 576 first = bisect_left(freebusy, period) 577 return freebusy[first:] 578 579 def periods_until(freebusy, period): 580 581 "Return the entries in 'freebusy' before 'period'." 582 583 last = bisect_right(freebusy, Period(period.get_end(), period.get_end(), period.get_tzid())) 584 return freebusy[:last] 585 586 def get_overlapping(freebusy, period): 587 588 """ 589 Return the entries in 'freebusy' providing periods overlapping with 590 'period'. 591 """ 592 593 # Find the range of periods potentially overlapping the period in the 594 # free/busy collection. 595 596 startpoints = periods_until(freebusy, period) 597 598 # Find the range of periods potentially overlapping the period in a version 599 # of the free/busy collection sorted according to end datetimes. 600 601 endpoints = [(Period(fb.get_end_point(), fb.get_end_point()), fb) for fb in startpoints] 602 endpoints.sort() 603 first = bisect_left(endpoints, (Period(period.get_start_point(), period.get_start_point()),)) 604 endpoints = endpoints[first:] 605 606 overlapping = set() 607 608 for p, fb in endpoints: 609 if fb.overlaps(period): 610 overlapping.add(fb) 611 612 overlapping = list(overlapping) 613 overlapping.sort() 614 return overlapping 615 616 def period_overlaps(freebusy, period, get_periods=False): 617 618 """ 619 Return whether any period in 'freebusy' overlaps with the given 'period', 620 returning a collection of overlapping periods if 'get_periods' is set to a 621 true value. 622 """ 623 624 overlapping = get_overlapping(freebusy, period) 625 626 if get_periods: 627 return overlapping 628 else: 629 return len(overlapping) != 0 630 631 def remove_overlapping(freebusy, period): 632 633 "Remove from 'freebusy' all periods overlapping with 'period'." 634 635 overlapping = get_overlapping(freebusy, period) 636 637 if overlapping: 638 for fb in overlapping: 639 freebusy.remove(fb) 640 641 def replace_overlapping(freebusy, period, replacements): 642 643 """ 644 Replace existing periods in 'freebusy' within the given 'period', using the 645 given 'replacements'. 646 """ 647 648 remove_overlapping(freebusy, period) 649 for replacement in replacements: 650 insert_period(freebusy, replacement) 651 652 def coalesce_freebusy(freebusy): 653 654 "Coalesce the periods in 'freebusy'." 655 656 if not freebusy: 657 return freebusy 658 659 fb = [] 660 start = freebusy[0].get_start_point() 661 end = freebusy[0].get_end_point() 662 663 for period in freebusy[1:]: 664 if period.get_start_point() > end: 665 fb.append(FreeBusyPeriod(start, end)) 666 start = period.get_start_point() 667 end = period.get_end_point() 668 else: 669 end = max(end, period.get_end_point()) 670 671 fb.append(FreeBusyPeriod(start, end)) 672 return fb 673 674 def invert_freebusy(freebusy): 675 676 "Return the free periods from 'freebusy'." 677 678 if not freebusy: 679 return [FreeBusyPeriod(None, None)] 680 681 # Coalesce periods that overlap or are adjacent. 682 683 fb = coalesce_freebusy(freebusy) 684 free = [] 685 686 # Add a start-of-time period if appropriate. 687 688 first = fb[0].get_start_point() 689 if first: 690 free.append(FreeBusyPeriod(None, first)) 691 692 start = fb[0].get_end_point() 693 694 for period in fb[1:]: 695 free.append(FreeBusyPeriod(start, period.get_start_point())) 696 start = period.get_end_point() 697 698 # Add an end-of-time period if appropriate. 699 700 if start: 701 free.append(FreeBusyPeriod(start, None)) 702 703 return free 704 705 def get_common_periods(all_freebusy): 706 707 "Return common periods to all collections in the 'all_freebusy' list." 708 709 if not all_freebusy: 710 return None 711 712 common = all_freebusy[0] 713 714 # Intersect periods with the current common set. 715 716 for freebusy in all_freebusy[1:]: 717 new_common = [] 718 719 # Find the overlapping periods and then obtain the regions that 720 # are common to both sets. 721 722 for period in freebusy: 723 for overlapping in get_overlapping(common, period): 724 p = period.common(overlapping) 725 if p: 726 new_common.append(p) 727 728 common = new_common 729 730 return common 731 732 # Period layout. 733 734 def get_scale(periods, tzid, view_period=None): 735 736 """ 737 Return a time scale from the given list of 'periods'. 738 739 The given 'tzid' is used to make sure that the times are defined according 740 to the chosen time zone. 741 742 An optional 'view_period' is used to constrain the scale to the given 743 period. 744 745 The returned scale is a mapping from time to (starting, ending) tuples, 746 where starting and ending are collections of periods. 747 """ 748 749 scale = {} 750 view_start = view_period and to_timezone(view_period.get_start_point(), tzid) or None 751 view_end = view_period and to_timezone(view_period.get_end_point(), tzid) or None 752 753 for p in periods: 754 755 # Add a point and this event to the starting list. 756 757 start = to_timezone(p.get_start(), tzid) 758 start = view_start and max(start, view_start) or start 759 if not scale.has_key(start): 760 scale[start] = [], [] 761 scale[start][0].append(p) 762 763 # Add a point and this event to the ending list. 764 765 end = to_timezone(p.get_end(), tzid) 766 end = view_end and min(end, view_end) or end 767 if not scale.has_key(end): 768 scale[end] = [], [] 769 scale[end][1].append(p) 770 771 return scale 772 773 class Point: 774 775 "A qualified point in time." 776 777 PRINCIPAL, REPEATED = 0, 1 778 779 def __init__(self, point, indicator=None): 780 self.point = point 781 self.indicator = indicator or self.PRINCIPAL 782 783 def __hash__(self): 784 return hash((self.point, self.indicator)) 785 786 def __cmp__(self, other): 787 if isinstance(other, Point): 788 return cmp((self.point, self.indicator), (other.point, other.indicator)) 789 elif isinstance(other, datetime): 790 return cmp(self.point, other) 791 else: 792 return 1 793 794 def __eq__(self, other): 795 return self.__cmp__(other) == 0 796 797 def __ne__(self, other): 798 return not self == other 799 800 def __lt__(self, other): 801 return self.__cmp__(other) < 0 802 803 def __le__(self, other): 804 return self.__cmp__(other) <= 0 805 806 def __gt__(self, other): 807 return not self <= other 808 809 def __ge__(self, other): 810 return not self < other 811 812 def __repr__(self): 813 return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL") 814 815 def get_slots(scale): 816 817 """ 818 Return an ordered list of time slots from the given 'scale'. 819 820 Each slot is a tuple containing details of a point in time for the start of 821 the slot, together with a list of parallel event periods. 822 823 Each point in time is described as a Point representing the actual point in 824 time together with an indicator of the nature of the point in time (as a 825 principal point in a time scale or as a repeated point used to terminate 826 events occurring for an instant in time). 827 """ 828 829 slots = [] 830 active = [] 831 832 points = scale.items() 833 points.sort() 834 835 for point, (starting, ending) in points: 836 ending = set(ending) 837 instants = ending.intersection(starting) 838 839 # Discard all active events ending at or before this start time. 840 # Free up the position in the active list. 841 842 for t in ending.difference(instants): 843 i = active.index(t) 844 active[i] = None 845 846 # For each event starting at the current point, fill any newly-vacated 847 # position or add to the end of the active list. 848 849 for t in starting: 850 try: 851 i = active.index(None) 852 active[i] = t 853 except ValueError: 854 active.append(t) 855 856 # Discard vacant positions from the end of the active list. 857 858 while active and active[-1] is None: 859 active.pop() 860 861 # Add an entry for the time point before "instants". 862 863 slots.append((Point(point), active[:])) 864 865 # Discard events ending at the same time as they began. 866 867 if instants: 868 for t in instants: 869 i = active.index(t) 870 active[i] = None 871 872 # Discard vacant positions from the end of the active list. 873 874 while active and active[-1] is None: 875 active.pop() 876 877 # Add another entry for the time point after "instants". 878 879 slots.append((Point(point, Point.REPEATED), active[:])) 880 881 return slots 882 883 def add_day_start_points(slots, tzid): 884 885 """ 886 Introduce into the 'slots' any day start points required by multi-day 887 periods. The 'tzid' is required to make sure that appropriate time zones 888 are chosen and not necessarily those provided by the existing time points. 889 """ 890 891 new_slots = [] 892 current_date = None 893 previously_active = [] 894 895 for point, active in slots: 896 start_of_day = get_start_of_day(point.point, tzid) 897 this_date = point.point.date() 898 899 # For each new day, add a slot for the start of the day where periods 900 # are active and where no such slot already exists. 901 902 if this_date != current_date: 903 904 # Fill in days where events remain active. 905 906 if current_date: 907 current_date += timedelta(1) 908 while current_date < this_date: 909 new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active)) 910 current_date += timedelta(1) 911 else: 912 current_date = this_date 913 914 # Add any continuing periods. 915 916 if point.point != start_of_day: 917 new_slots.append((Point(start_of_day), previously_active)) 918 919 # Add the currently active periods at this point in time. 920 921 previously_active = active 922 923 for t in new_slots: 924 insort_left(slots, t) 925 926 def remove_end_slot(slots, view_period): 927 928 """ 929 Remove from 'slots' any slot situated at the end of the given 'view_period'. 930 """ 931 932 end = view_period.get_end_point() 933 if not end or not slots: 934 return 935 i = bisect_left(slots, (Point(end), None)) 936 if i < len(slots): 937 del slots[i:] 938 939 def add_slots(slots, points): 940 941 """ 942 Introduce into the 'slots' entries for those in 'points' that are not 943 already present, propagating active periods from time points preceding 944 those added. 945 """ 946 947 new_slots = [] 948 949 for point in points: 950 i = bisect_left(slots, (point,)) # slots is [(point, active)...] 951 if i < len(slots) and slots[i][0] == point: 952 continue 953 954 new_slots.append((point, i > 0 and slots[i-1][1] or [])) 955 956 for t in new_slots: 957 insort_left(slots, t) 958 959 def partition_by_day(slots): 960 961 """ 962 Return a mapping from dates to time points provided by 'slots'. 963 """ 964 965 d = {} 966 967 for point, value in slots: 968 day = point.point.date() 969 if not d.has_key(day): 970 d[day] = [] 971 d[day].append((point, value)) 972 973 return d 974 975 def add_empty_days(days, tzid, start=None, end=None): 976 977 """ 978 Add empty days to 'days' between busy days, and optionally from the given 979 'start' day and until the given 'end' day. 980 """ 981 982 last_day = start - timedelta(1) 983 all_days = days.keys() 984 all_days.sort() 985 986 for day in all_days: 987 if last_day: 988 empty_day = last_day + timedelta(1) 989 while empty_day < day: 990 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)] 991 empty_day += timedelta(1) 992 last_day = day 993 994 if end: 995 empty_day = last_day + timedelta(1) 996 while empty_day < end: 997 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)] 998 empty_day += timedelta(1) 999 1000 def get_spans(slots): 1001 1002 "Inspect the given 'slots', returning a mapping of period keys to spans." 1003 1004 points = [point for point, active in slots] 1005 spans = {} 1006 1007 for _point, active in slots: 1008 for p in active: 1009 if p: 1010 key = p.get_key() 1011 start_slot = bisect_left(points, p.get_start()) 1012 end_slot = bisect_left(points, p.get_end()) 1013 spans[key] = end_slot - start_slot 1014 1015 return spans 1016 1017 def update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None): 1018 1019 """ 1020 Update the free/busy details with the given 'periods', 'transp' setting, 1021 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. 1022 1023 An optional 'expires' datetime string indicates the expiry time of any 1024 free/busy offer. 1025 """ 1026 1027 remove_period(freebusy, uid, recurrenceid) 1028 1029 for p in periods: 1030 insert_period(freebusy, FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires)) 1031 1032 # vim: tabstop=4 expandtab shiftwidth=4