1 #!/usr/bin/env python 2 3 """ 4 Recurrence rule calculation. 5 6 Copyright (C) 2014, 2015, 2017 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 23 References: 24 25 RFC 5545: Internet Calendaring and Scheduling Core Object Specification 26 (iCalendar) 27 http://tools.ietf.org/html/rfc5545 28 29 ---- 30 31 FREQ defines the selection resolution. 32 DTSTART defines the start of the selection. 33 INTERVAL defines the step of the selection. 34 COUNT defines a number of instances 35 UNTIL defines a limit to the selection. 36 37 BY... qualifiers select instances within each outer selection instance according 38 to the recurrence of instances of the next highest resolution. For example, 39 BYDAY selects days in weeks. Thus, if no explicit week recurrence is indicated, 40 all weeks are selected within the selection of the next highest explicitly 41 specified resolution, whether this is months or years. 42 43 BYSETPOS in conjunction with BY... qualifiers permit the selection of specific 44 instances. 45 46 Within the FREQ resolution, BY... qualifiers refine selected instances. 47 48 Outside the FREQ resolution, BY... qualifiers select instances at the resolution 49 concerned. 50 51 Thus, FREQ and BY... qualifiers need to be ordered in terms of increasing 52 resolution (or decreasing scope). 53 """ 54 55 from calendar import monthrange 56 from datetime import date, datetime, timedelta 57 import operator 58 59 # Frequency levels, specified by FREQ in iCalendar. 60 61 freq_levels = ( 62 "YEARLY", 63 "MONTHLY", 64 "WEEKLY", 65 None, 66 None, 67 "DAILY", 68 "HOURLY", 69 "MINUTELY", 70 "SECONDLY" 71 ) 72 73 # Enumeration levels, employed by BY... qualifiers. 74 75 enum_levels = ( 76 None, 77 "BYMONTH", 78 "BYWEEKNO", 79 "BYYEARDAY", 80 "BYMONTHDAY", 81 "BYDAY", 82 "BYHOUR", 83 "BYMINUTE", 84 "BYSECOND" 85 ) 86 87 # Levels defining days. 88 89 daylevels = [2, 3, 4, 5] 90 91 # Map from levels to lengths of datetime tuples. 92 93 lengths = [1, 2, 3, 3, 3, 3, 4, 5, 6] 94 positions = [l-1 for l in lengths] 95 96 # Define the lowest values at each resolution (years, months, days... hours, 97 # minutes, seconds). 98 99 firstvalues = [0, 1, 1, 1, 1, 1, 0, 0, 0] 100 101 # Map from qualifiers to interval units. Here, weeks are defined as 7 days. 102 103 units = {"WEEKLY" : 7} 104 105 # Make dictionaries mapping qualifiers to levels. 106 107 freq = dict([(level, i) for (i, level) in enumerate(freq_levels) if level]) 108 enum = dict([(level, i) for (i, level) in enumerate(enum_levels) if level]) 109 weekdays = dict([(weekday, i+1) for (i, weekday) in enumerate(["MO", "TU", "WE", "TH", "FR", "SA", "SU"])]) 110 111 # Functions for structuring the recurrences. 112 113 def get_next(it): 114 try: 115 return it.next() 116 except StopIteration: 117 return None 118 119 def get_parameters(values): 120 121 "Return parameters from the given list of 'values'." 122 123 d = {} 124 for value in values: 125 parts = value.split("=", 1) 126 if len(parts) < 2: 127 continue 128 key, value = parts 129 if key in ("COUNT", "BYSETPOS"): 130 d[key] = int(value) 131 else: 132 d[key] = value 133 return d 134 135 def get_qualifiers(values): 136 137 """ 138 Process the list of 'values' of the form "key=value", returning a list of 139 qualifiers of the form (qualifier name, args). 140 """ 141 142 qualifiers = [] 143 frequency = None 144 interval = 1 145 146 for value in values: 147 parts = value.split("=", 1) 148 if len(parts) < 2: 149 continue 150 key, value = parts 151 152 # Accept frequency indicators as qualifiers. 153 154 if key == "FREQ" and freq.has_key(value): 155 qualifier = frequency = (value, {}) 156 157 # Accept interval indicators for frequency qualifier parameterisation. 158 159 elif key == "INTERVAL": 160 interval = int(value) 161 continue 162 163 # Accept enumerators as qualifiers. 164 165 elif enum.has_key(key): 166 qualifier = (key, {"values" : get_qualifier_values(key, value)}) 167 168 # Ignore other items. 169 170 else: 171 continue 172 173 qualifiers.append(qualifier) 174 175 # Parameterise any frequency qualifier with the interval. 176 177 if frequency: 178 frequency[1]["interval"] = interval 179 180 return qualifiers 181 182 def get_qualifier_values(qualifier, value): 183 184 """ 185 For the given 'qualifier', process the 'value' string, returning a list of 186 suitable values. 187 """ 188 189 if qualifier != "BYDAY": 190 return map(int, value.split(",")) 191 192 values = [] 193 for part in value.split(","): 194 weekday = weekdays.get(part[-2:]) 195 if not weekday: 196 continue 197 index = part[:-2] 198 if index: 199 index = int(index) 200 else: 201 index = None 202 values.append((weekday, index)) 203 204 return values 205 206 def order_qualifiers(qualifiers): 207 208 "Return the 'qualifiers' in order of increasing resolution." 209 210 l = [] 211 212 for qualifier, args in qualifiers: 213 214 # Distinguish between enumerators, used to select particular periods, 215 # and frequencies, used to select repeating periods. 216 217 if enum.has_key(qualifier): 218 level = enum[qualifier] 219 if special_enum_levels.has_key(qualifier): 220 args["interval"] = 1 221 selector = special_enum_levels[qualifier] 222 else: 223 selector = Enum 224 else: 225 level = freq[qualifier] 226 selector = Pattern 227 228 l.append(selector(level, args, qualifier)) 229 230 l.sort(key=lambda x: x.level) 231 return l 232 233 def get_datetime_structure(datetime): 234 235 "Return the structure of 'datetime' for recurrence production." 236 237 l = [] 238 offset = 0 239 240 for level, value in enumerate(datetime): 241 242 # At the day number, adjust the frequency level offset to reference 243 # days (and then hours, minutes, seconds). 244 245 if level == 2: 246 offset = 3 247 248 l.append(Enum(level + offset, {"values" : [value]}, "DT")) 249 250 return l 251 252 def combine_datetime_with_qualifiers(datetime, qualifiers): 253 254 """ 255 Combine 'datetime' with 'qualifiers' to produce a structure for recurrence 256 production. 257 258 Initial datetime values appearing at broader resolutions than any qualifiers 259 are ignored, since their details will be used when materialising the 260 results. 261 262 Qualifiers are accumulated in order to define a selection. Datetime values 263 occurring between qualifiers or at the same resolution as qualifiers are 264 ignored. 265 266 Any remaining datetime values are introduced as enumerators, provided that 267 they do not conflict with qualifiers. For example, specific day values 268 conflict with day selectors and weekly qualifiers. 269 270 The purpose of the remaining datetime values is to define a result within 271 a period selected by the most precise qualifier. For example, the selection 272 of a day and month in a year recurrence. 273 """ 274 275 iter_dt = iter(get_datetime_structure(datetime)) 276 iter_q = iter(order_qualifiers(qualifiers)) 277 278 l = [] 279 280 from_dt = get_next(iter_dt) 281 from_q = get_next(iter_q) 282 have_q = False 283 284 # Consume from both lists, merging entries. 285 286 while from_dt and from_q: 287 _level = from_dt.level 288 level = from_q.level 289 290 # Datetime value at wider resolution. 291 292 if _level < level: 293 from_dt = get_next(iter_dt) 294 295 # Qualifier at wider or same resolution as datetime value. 296 297 else: 298 if not have_q: 299 add_initial_qualifier(from_q, level, l) 300 have_q = True 301 302 # Add the qualifier to the combined list. 303 304 l.append(from_q) 305 306 # Datetime value at same resolution. 307 308 if _level == level: 309 from_dt = get_next(iter_dt) 310 311 # Get the next qualifier. 312 313 from_q = get_next(iter_q) 314 315 # Complete the list by adding remaining datetime enumerators. 316 317 while from_dt: 318 319 # Ignore datetime values that conflict with day-level qualifiers. 320 321 if not l or from_dt.level != freq["DAILY"] or l[-1].level not in daylevels: 322 l.append(from_dt) 323 324 from_dt = get_next(iter_dt) 325 326 # Complete the list by adding remaining qualifiers. 327 328 while from_q: 329 if not have_q: 330 add_initial_qualifier(from_q, level, l) 331 have_q = True 332 333 # Add the qualifier to the combined list. 334 335 l.append(from_q) 336 337 # Get the next qualifier. 338 339 from_q = get_next(iter_q) 340 341 return l 342 343 def add_initial_qualifier(from_q, level, l): 344 345 """ 346 Take the first qualifier 'from_q' at the given resolution 'level', using it 347 to create an initial qualifier, adding it to the combined list 'l' if 348 required. 349 """ 350 351 if isinstance(from_q, Enum) and level > 0: 352 repeat = Pattern(level - 1, {"interval" : 1}, None) 353 l.append(repeat) 354 355 # Datetime arithmetic. 356 357 def combine(t1, t2): 358 359 """ 360 Combine tuples 't1' and 't2', returning a copy of 't1' filled with values 361 from 't2' in positions where 0 appeared in 't1'. 362 """ 363 364 return tuple(map(lambda x, y: x or y, t1, t2)) 365 366 def scale(interval, pos): 367 368 """ 369 Scale the given 'interval' value to the indicated position 'pos', returning 370 a tuple with leading zero elements and 'interval' at the stated position. 371 """ 372 373 return (0,) * pos + (interval,) 374 375 def get_seconds(t): 376 377 "Convert the sub-day part of 't' into seconds." 378 379 t = t + (0,) * (6 - len(t)) 380 return (t[3] * 60 + t[4]) * 60 + t[5] 381 382 def update(t, step): 383 384 "Update 't' by 'step' at the resolution of 'step'." 385 386 i = len(step) 387 388 # Years only. 389 390 if i == 1: 391 return (t[0] + step[0],) + tuple(t[1:]) 392 393 # Years and months. 394 395 elif i == 2: 396 month = t[1] + step[1] 397 return (t[0] + step[0] + (month - 1) / 12, (month - 1) % 12 + 1) + tuple(t[2:]) 398 399 # Dates and datetimes. 400 401 else: 402 updated_for_months = update(t, step[:2]) 403 404 # Dates only. 405 406 if i == 3: 407 d = datetime(*updated_for_months) 408 s = timedelta(step[2]) 409 410 # Datetimes. 411 412 else: 413 d = datetime(*updated_for_months) 414 s = timedelta(step[2], get_seconds(step)) 415 416 return to_tuple(d + s, len(t)) 417 418 def to_tuple(d, n=None): 419 420 "Return 'd' as a tuple, optionally trimming the result to 'n' positions." 421 422 if not isinstance(d, date): 423 return d 424 if n is None: 425 if isinstance(d, datetime): 426 n = 6 427 else: 428 n = 3 429 return d.timetuple()[:n] 430 431 def get_first_day(first_day, weekday): 432 433 "Return the first occurrence at or after 'first_day' of 'weekday'." 434 435 first_day = date(*first_day) 436 first_weekday = first_day.isoweekday() 437 if first_weekday > weekday: 438 return first_day + timedelta(7 - first_weekday + weekday) 439 else: 440 return first_day + timedelta(weekday - first_weekday) 441 442 def get_last_day(last_day, weekday): 443 444 "Return the last occurrence at or before 'last_day' of 'weekday'." 445 446 last_day = date(*last_day) 447 last_weekday = last_day.isoweekday() 448 if last_weekday < weekday: 449 return last_day - timedelta(last_weekday + 7 - weekday) 450 else: 451 return last_day - timedelta(last_weekday - weekday) 452 453 # Classes for producing instances from recurrence structures. 454 455 class Selector: 456 457 "A generic selector." 458 459 def __init__(self, level, args, qualifier, selecting=None, first=False): 460 461 """ 462 Initialise at the given 'level' a selector employing the given 'args' 463 defined in the interpretation of recurrence rule qualifiers, with the 464 'qualifier' being the name of the rule qualifier, and 'selecting' being 465 an optional selector used to find more specific instances from those 466 found by this selector. 467 """ 468 469 self.level = level 470 self.args = args 471 self.qualifier = qualifier 472 self.selecting = selecting 473 self.first = first 474 475 # Define the index of values from datetimes involved with this selector. 476 477 self.pos = positions[level] 478 479 def __repr__(self): 480 return "%s(%r, %r, %r, %r)" % (self.__class__.__name__, self.level, self.args, self.qualifier, self.first) 481 482 def materialise(self, start, end, count=None, setpos=None, inclusive=False): 483 484 """ 485 Starting at 'start', materialise instances up to but not including any 486 at 'end' or later, returning at most 'count' if specified, and returning 487 only the occurrences indicated by 'setpos' if specified. A list of 488 instances is returned. 489 490 If 'inclusive' is specified, the selection of instances will include the 491 end of the search period if present in the results. 492 """ 493 494 start = to_tuple(start) 495 end = to_tuple(end) 496 counter = count and [0, count] 497 results = self.materialise_items(start, start, end, counter, setpos, inclusive) 498 results.sort() 499 return results[:count] 500 501 def materialise_item(self, current, earliest, next, counter, setpos=None, inclusive=False): 502 503 """ 504 Given the 'current' instance, the 'earliest' acceptable instance, the 505 'next' instance, an instance 'counter', and the optional 'setpos' 506 criteria, return a list of result items. Where no selection within the 507 current instance occurs, the current instance will be returned as a 508 result if the same or later than the earliest acceptable instance. 509 """ 510 511 if self.selecting: 512 return self.selecting.materialise_items(current, earliest, next, counter, setpos, inclusive) 513 elif earliest <= current: 514 return [current] 515 else: 516 return [] 517 518 def convert_positions(self, setpos): 519 520 "Convert 'setpos' to 0-based indexes." 521 522 l = [] 523 for pos in setpos: 524 lower = pos < 0 and pos or pos - 1 525 upper = pos > 0 and pos or pos < -1 and pos + 1 or None 526 l.append((lower, upper)) 527 return l 528 529 def select_positions(self, results, setpos): 530 531 "Select in 'results' the 1-based positions given by 'setpos'." 532 533 results.sort() 534 l = [] 535 for lower, upper in self.convert_positions(setpos): 536 l += results[lower:upper] 537 return l 538 539 def filter_by_period(self, results, start, end, inclusive): 540 541 """ 542 Filter 'results' so that only those at or after 'start' and before 'end' 543 are returned. 544 545 If 'inclusive' is specified, the selection of instances will include the 546 end of the search period if present in the results. 547 """ 548 549 l = [] 550 for result in results: 551 if start <= result and (inclusive and result <= end or result < end): 552 l.append(result) 553 return l 554 555 class Pattern(Selector): 556 557 "A selector of time periods according to a repeating pattern." 558 559 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 560 561 """ 562 Bounded by the given 'context', return periods within 'start' to 'end', 563 updating the 'counter', selecting only the indexes specified by 'setpos' 564 (if given). 565 566 If 'inclusive' is specified, the selection of periods will include those 567 starting at the end of the search period, if present in the results. 568 """ 569 570 # Define the step between result periods. 571 572 interval = self.args.get("interval", 1) * units.get(self.qualifier, 1) 573 step = scale(interval, self.pos) 574 575 # Define the scale of a single period. 576 577 unit_interval = units.get(self.qualifier, 1) 578 unit_step = scale(unit_interval, self.pos) 579 580 # Employ the context as the current period if this is the first 581 # qualifier in the selection chain. 582 583 if self.first: 584 current = context[:self.pos+1] 585 586 # Otherwise, obtain the first value at this resolution within the 587 # context period. 588 589 else: 590 first = scale(firstvalues[self.level], self.pos) 591 current = combine(context[:self.pos], first) 592 593 results = [] 594 595 # Obtain periods before the end (and also at the end if inclusive), 596 # provided that any limit imposed by the counter has not been exceeded. 597 598 while (inclusive and current <= end or current < end) and \ 599 (counter is None or counter[0] < counter[1]): 600 601 # Increment the current datetime by the step for the next period. 602 603 next = update(current, step) 604 605 # Determine the end point of the current period. 606 607 current_end = update(current, unit_step) 608 609 # Obtain any period or periods within the bounds defined by the 610 # current period and any contraining start and end points, plus 611 # counter, setpos and inclusive details. 612 613 interval_results = self.materialise_item(current, max(current, start), min(current_end, end), counter, setpos, inclusive) 614 615 # Update the counter with the number of identified results. 616 617 if counter is not None: 618 counter[0] += len(interval_results) 619 620 # Accumulate the results. 621 622 results += interval_results 623 624 # Visit the next instance. 625 626 current = next 627 628 return results 629 630 class WeekDayFilter(Selector): 631 632 "A selector of instances specified in terms of day numbers." 633 634 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 635 step = scale(1, 2) 636 results = [] 637 638 # Get weekdays in the year. 639 640 if len(context) == 1: 641 first_day = (context[0], 1, 1) 642 last_day = (context[0], 12, 31) 643 644 # Get weekdays in the month. 645 646 elif len(context) == 2: 647 first_day = (context[0], context[1], 1) 648 last_day = update((context[0], context[1], 1), (0, 1, 0)) 649 last_day = update(last_day, (0, 0, -1)) 650 651 # Get weekdays in the week. 652 653 else: 654 current = context 655 values = [value for (value, index) in self.args["values"]] 656 657 while (inclusive and current <= end or current < end): 658 next = update(current, step) 659 if date(*current).isoweekday() in values: 660 results += self.materialise_item(current, max(current, start), min(next, end), counter, inclusive=inclusive) 661 current = next 662 663 if setpos: 664 return self.select_positions(results, setpos) 665 else: 666 return results 667 668 # Find each of the given days. 669 670 for value, index in self.args["values"]: 671 if index is not None: 672 offset = timedelta(7 * (abs(index) - 1)) 673 674 if index < 0: 675 current = to_tuple(get_last_day(last_day, value) - offset, 3) 676 else: 677 current = to_tuple(get_first_day(first_day, value) + offset, 3) 678 679 next = update(current, step) 680 681 # To support setpos, only current and next bound the search, not 682 # the period in addition. 683 684 results += self.materialise_item(current, current, next, counter, inclusive=inclusive) 685 686 else: 687 if index < 0: 688 current = to_tuple(get_last_day(last_day, value), 3) 689 direction = operator.sub 690 else: 691 current = to_tuple(get_first_day(first_day, value), 3) 692 direction = operator.add 693 694 while first_day <= current <= last_day: 695 next = update(current, step) 696 697 # To support setpos, only current and next bound the search, not 698 # the period in addition. 699 700 results += self.materialise_item(current, current, next, counter, inclusive=inclusive) 701 current = to_tuple(direction(date(*current), timedelta(7)), 3) 702 703 # Extract selected positions and remove out-of-period instances. 704 705 if setpos: 706 results = self.select_positions(results, setpos) 707 708 return self.filter_by_period(results, start, end, inclusive) 709 710 class Enum(Selector): 711 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 712 step = scale(1, self.pos) 713 results = [] 714 for value in self.args["values"]: 715 current = combine(context[:self.pos], scale(value, self.pos)) 716 next = update(current, step) 717 718 # To support setpos, only current and next bound the search, not 719 # the period in addition. 720 721 results += self.materialise_item(current, current, next, counter, setpos, inclusive) 722 723 # Extract selected positions and remove out-of-period instances. 724 725 if setpos: 726 results = self.select_positions(results, setpos) 727 728 return self.filter_by_period(results, start, end, inclusive) 729 730 class MonthDayFilter(Enum): 731 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 732 last_day = monthrange(context[0], context[1])[1] 733 step = scale(1, self.pos) 734 results = [] 735 for value in self.args["values"]: 736 if value < 0: 737 value = last_day + 1 + value 738 current = combine(context, scale(value, self.pos)) 739 next = update(current, step) 740 741 # To support setpos, only current and next bound the search, not 742 # the period in addition. 743 744 results += self.materialise_item(current, current, next, counter, inclusive=inclusive) 745 746 # Extract selected positions and remove out-of-period instances. 747 748 if setpos: 749 results = self.select_positions(results, setpos) 750 751 return self.filter_by_period(results, start, end, inclusive) 752 753 class YearDayFilter(Enum): 754 def materialise_items(self, context, start, end, counter, setpos=None, inclusive=False): 755 first_day = date(context[0], 1, 1) 756 next_first_day = date(context[0] + 1, 1, 1) 757 year_length = (next_first_day - first_day).days 758 step = scale(1, self.pos) 759 results = [] 760 for value in self.args["values"]: 761 if value < 0: 762 value = year_length + 1 + value 763 current = to_tuple(first_day + timedelta(value - 1), 3) 764 next = update(current, step) 765 766 # To support setpos, only current and next bound the search, not 767 # the period in addition. 768 769 results += self.materialise_item(current, current, next, counter, inclusive=inclusive) 770 771 # Extract selected positions and remove out-of-period instances. 772 773 if setpos: 774 results = self.select_positions(results, setpos) 775 776 return self.filter_by_period(results, start, end, inclusive) 777 778 special_enum_levels = { 779 "BYDAY" : WeekDayFilter, 780 "BYMONTHDAY" : MonthDayFilter, 781 "BYYEARDAY" : YearDayFilter, 782 } 783 784 # Public functions. 785 786 def connect_selectors(selectors): 787 788 """ 789 Make the 'selectors' reference each other in a hierarchy so that 790 materialising the principal selector causes the more specific ones to be 791 employed in the operation. 792 """ 793 794 current = selectors[0] 795 current.first = True 796 for selector in selectors[1:]: 797 current.selecting = selector 798 current = selector 799 return selectors[0] 800 801 def get_selector(dt, qualifiers): 802 803 """ 804 Combine the initial datetime 'dt' with the given 'qualifiers', returning an 805 object that can be used to select recurrences described by the 'qualifiers'. 806 """ 807 808 dt = to_tuple(dt) 809 return connect_selectors(combine_datetime_with_qualifiers(dt, qualifiers)) 810 811 def get_rule(dt, rule): 812 813 """ 814 Using the given initial datetime 'dt', interpret the 'rule' (a semicolon- 815 separated collection of "key=value" strings), and return the resulting 816 selector object. 817 """ 818 819 if not isinstance(rule, tuple): 820 rule = rule.split(";") 821 qualifiers = get_qualifiers(rule) 822 return get_selector(dt, qualifiers) 823 824 # vim: tabstop=4 expandtab shiftwidth=4