paul@68 | 1 | # -*- coding: iso-8859-1 -*- |
paul@68 | 2 | """ |
paul@68 | 3 | MoinMoin - Calendar recurrence support |
paul@68 | 4 | |
paul@68 | 5 | @copyright: 2013 by Paul Boddie <paul@boddie.org.uk> |
paul@68 | 6 | @license: GNU GPL (v2 or later), see COPYING.txt for details. |
paul@68 | 7 | |
paul@68 | 8 | Supported grammar: |
paul@68 | 9 | |
paul@68 | 10 | <recurrence> [ of <recurrence> ]... |
paul@68 | 11 | |
paul@68 | 12 | recurrence = <specific-recurrence> | <repeating-recurrence> |
paul@68 | 13 | |
paul@71 | 14 | specific-recurrence = ( the <qualifier> <interval> | <month-label> ) |
paul@68 | 15 | [ in <specific-recurrence> ] |
paul@71 | 16 | | <concrete-datetime> |
paul@68 | 17 | |
paul@68 | 18 | repeating-recurrence = every [ <qualifier> ] <interval> |
paul@68 | 19 | [ from <specific-recurrence> ] |
paul@68 | 20 | [ until <specific-recurrence> ] |
paul@69 | 21 | |
paul@71 | 22 | concrete-datetime = <datetime> | <year> |
paul@70 | 23 | |
paul@69 | 24 | Constraints: |
paul@69 | 25 | |
paul@69 | 26 | repeating-recurrence: if <qualifier> is not "single": |
paul@69 | 27 | from and/or until must be specified |
paul@71 | 28 | |
paul@71 | 29 | from and/or until <specific-recurrence> resolutions |
paul@71 | 30 | must be compatible with the repeating-recurrence |
paul@68 | 31 | """ |
paul@68 | 32 | |
paul@70 | 33 | from DateSupport import weekday_labels, weekday_labels_verbose, month_labels, \ |
paul@70 | 34 | getDate, getMonth |
paul@68 | 35 | |
paul@68 | 36 | qualifiers = { |
paul@68 | 37 | "2nd" : 2, |
paul@68 | 38 | "third" : 3, |
paul@68 | 39 | "3rd" : 3, |
paul@68 | 40 | "fourth" : 4, |
paul@68 | 41 | "fifth" : 5, |
paul@68 | 42 | # stop at this point, supporting also "nth" and "n" |
paul@68 | 43 | } |
paul@68 | 44 | |
paul@68 | 45 | def isQualifier(qualifier, qualifiers): |
paul@69 | 46 | |
paul@69 | 47 | """ |
paul@69 | 48 | Return whether the 'qualifier' is one of the given 'qualifiers', returning |
paul@69 | 49 | the level of the qualifier. |
paul@69 | 50 | """ |
paul@69 | 51 | |
paul@68 | 52 | if qualifiers.has_key(qualifier): |
paul@68 | 53 | return qualifiers[qualifier] |
paul@68 | 54 | if qualifier.endswith("th") or qualifier.endswith("nd") or qualifier.endswith("st"): |
paul@68 | 55 | qualifier = qualifier[:-2] |
paul@68 | 56 | try: |
paul@68 | 57 | return int(qualifier) |
paul@68 | 58 | except ValueError: |
paul@68 | 59 | return False |
paul@68 | 60 | |
paul@68 | 61 | # Specific qualifiers refer to specific entities such as... |
paul@68 | 62 | # "the second Monday of every other month" |
paul@68 | 63 | # -> month 1 (Monday #2), month 3 (Monday #2), month 5 (Monday #2) |
paul@68 | 64 | |
paul@68 | 65 | specific_qualifiers = { |
paul@68 | 66 | "first" : 1, |
paul@68 | 67 | "1st" : 1, |
paul@68 | 68 | "second" : 2, # other is not permitted |
paul@68 | 69 | "last" : -1, |
paul@68 | 70 | "final" : -1, |
paul@68 | 71 | } |
paul@68 | 72 | |
paul@68 | 73 | specific_qualifiers.update(qualifiers) |
paul@68 | 74 | |
paul@68 | 75 | def isSpecificQualifier(qualifier): |
paul@68 | 76 | return isQualifier(qualifier, specific_qualifiers) |
paul@68 | 77 | |
paul@68 | 78 | # Repeating qualifiers refer to repeating entities such as... |
paul@68 | 79 | # "every other Monday of every other month" |
paul@68 | 80 | # -> month 1 (Monday #2, #4), month 3 (Monday #2, #4) |
paul@68 | 81 | |
paul@68 | 82 | repeating_qualifiers = { |
paul@68 | 83 | "single" : 1, |
paul@68 | 84 | "other" : 2, # second is not permitted (it clashes with the interval) |
paul@68 | 85 | } |
paul@68 | 86 | |
paul@68 | 87 | repeating_qualifiers.update(qualifiers) |
paul@68 | 88 | |
paul@68 | 89 | def isRepeatingQualifier(qualifier): |
paul@68 | 90 | return isQualifier(qualifier, repeating_qualifiers) |
paul@68 | 91 | |
paul@68 | 92 | intervals = { |
paul@68 | 93 | "second" : 1, |
paul@68 | 94 | "minute" : 2, |
paul@68 | 95 | "hour" : 3, |
paul@68 | 96 | "day" : 4, |
paul@68 | 97 | "week" : 5, |
paul@68 | 98 | "month" : 6, |
paul@68 | 99 | "year" : 7 |
paul@68 | 100 | } |
paul@68 | 101 | |
paul@68 | 102 | # NOTE: Support day and month abbreviations in the input. |
paul@68 | 103 | |
paul@68 | 104 | for day in weekday_labels: |
paul@68 | 105 | intervals[day] = intervals["day"] |
paul@68 | 106 | |
paul@70 | 107 | for day in weekday_labels_verbose: |
paul@70 | 108 | intervals[day] = intervals["day"] |
paul@70 | 109 | |
paul@68 | 110 | for month in month_labels: |
paul@68 | 111 | intervals[month] = intervals["month"] |
paul@68 | 112 | |
paul@68 | 113 | # Parsing-related classes. |
paul@68 | 114 | |
paul@68 | 115 | class ParseError(Exception): |
paul@68 | 116 | pass |
paul@68 | 117 | |
paul@69 | 118 | class VerifyError(Exception): |
paul@69 | 119 | pass |
paul@69 | 120 | |
paul@68 | 121 | class ParseIterator: |
paul@68 | 122 | def __init__(self, iterator): |
paul@68 | 123 | self.iterator = iterator |
paul@68 | 124 | self.tokens = [] |
paul@68 | 125 | self.end = False |
paul@68 | 126 | |
paul@68 | 127 | def want(self): |
paul@68 | 128 | try: |
paul@68 | 129 | return self._next() |
paul@68 | 130 | except StopIteration: |
paul@68 | 131 | self.end = True |
paul@68 | 132 | return None |
paul@68 | 133 | |
paul@68 | 134 | def next(self): |
paul@68 | 135 | try: |
paul@68 | 136 | return self._next() |
paul@68 | 137 | except StopIteration: |
paul@68 | 138 | self.end = True |
paul@68 | 139 | raise ParseError, self.tokens |
paul@68 | 140 | |
paul@68 | 141 | def need(self, token): |
paul@68 | 142 | t = self._next() |
paul@68 | 143 | if t != token: |
paul@68 | 144 | raise ParseError, self.tokens |
paul@68 | 145 | |
paul@70 | 146 | def have(self, token=None): |
paul@68 | 147 | if self.end: |
paul@68 | 148 | return False |
paul@68 | 149 | t = self.tokens[-1] |
paul@70 | 150 | if token: |
paul@70 | 151 | return t == token |
paul@70 | 152 | else: |
paul@70 | 153 | return t |
paul@68 | 154 | |
paul@71 | 155 | def have_one_of(self, tokens): |
paul@71 | 156 | if self.end: |
paul@71 | 157 | return False |
paul@71 | 158 | t = self.tokens[-1] |
paul@71 | 159 | return t in tokens |
paul@71 | 160 | |
paul@68 | 161 | def _next(self): |
paul@68 | 162 | t = self.iterator.next() |
paul@68 | 163 | self.tokens.append(t) |
paul@68 | 164 | return t |
paul@68 | 165 | |
paul@68 | 166 | class Selector: |
paul@70 | 167 | |
paul@70 | 168 | "A selector of datetime occurrences at a particular interval resolution." |
paul@70 | 169 | |
paul@68 | 170 | def __init__(self, qualified_by=None): |
paul@68 | 171 | self.recurrence_type = None |
paul@68 | 172 | self.qualifier = None |
paul@68 | 173 | self.qualifier_level = None |
paul@68 | 174 | self.interval = None |
paul@68 | 175 | self.interval_level = None |
paul@68 | 176 | self.qualified_by = qualified_by |
paul@68 | 177 | self.from_datetime = None |
paul@68 | 178 | self.until_datetime = None |
paul@68 | 179 | |
paul@68 | 180 | def add_details(self, recurrence_type, qualifier, qualifier_level, interval, interval_level): |
paul@68 | 181 | self.recurrence_type = recurrence_type |
paul@68 | 182 | self.qualifier = qualifier |
paul@68 | 183 | self.qualifier_level = qualifier_level |
paul@68 | 184 | self.interval = interval |
paul@68 | 185 | self.interval_level = interval_level |
paul@68 | 186 | |
paul@70 | 187 | def add_datetime(self, interval, interval_level, value): |
paul@70 | 188 | self.recurrence_type = "the" |
paul@70 | 189 | self.qualifier = str(value) |
paul@70 | 190 | self.qualifier_level = value |
paul@70 | 191 | self.interval = interval |
paul@70 | 192 | self.interval_level = interval_level |
paul@70 | 193 | |
paul@68 | 194 | def set_from(self, from_datetime): |
paul@68 | 195 | self.from_datetime = from_datetime |
paul@68 | 196 | |
paul@68 | 197 | def set_until(self, until_datetime): |
paul@68 | 198 | self.until_datetime = until_datetime |
paul@68 | 199 | |
paul@68 | 200 | def __str__(self): |
paul@70 | 201 | return "%s%s%s%s%s%s" % ( |
paul@70 | 202 | self.recurrence_type or "", |
paul@70 | 203 | self.qualifier and " %s" % self.qualifier or "", |
paul@70 | 204 | self.interval and " %s" % self.interval or "", |
paul@68 | 205 | self.from_datetime and " from {%s}" % self.from_datetime or "", |
paul@68 | 206 | self.until_datetime and " until {%s}" % self.until_datetime or "", |
paul@69 | 207 | self.qualified_by and ", selecting %s" % self.qualified_by or "") |
paul@68 | 208 | |
paul@68 | 209 | def __repr__(self): |
paul@68 | 210 | return "Selector(%s, %s, %s%s%s%s>" % ( |
paul@68 | 211 | self.recurrence_type, self.qualifier, self.interval, |
paul@68 | 212 | self.from_datetime and ", from_datetime=%r" % self.from_datetime or "", |
paul@68 | 213 | self.until_datetime and ", until_datetime=%r" % self.until_datetime or "", |
paul@68 | 214 | self.qualified_by and ", qualified_by=%r" % self.qualified_by or "") |
paul@68 | 215 | |
paul@71 | 216 | def get_resolution(self): |
paul@71 | 217 | if self.qualified_by: |
paul@71 | 218 | return self.qualified_by.get_resolution() |
paul@71 | 219 | else: |
paul@71 | 220 | return self.interval_level |
paul@70 | 221 | |
paul@68 | 222 | # Parsing functions. |
paul@68 | 223 | |
paul@68 | 224 | def getRecurrence(s): |
paul@68 | 225 | |
paul@68 | 226 | "Interpret the given string 's', returning a recurrence description." |
paul@68 | 227 | |
paul@68 | 228 | words = ParseIterator(iter([w.strip() for w in s.split()])) |
paul@68 | 229 | |
paul@68 | 230 | current = Selector() |
paul@68 | 231 | |
paul@68 | 232 | current = parseRecurrence(words, current) |
paul@68 | 233 | parseOptionalLimits(words, current) |
paul@68 | 234 | |
paul@68 | 235 | # Obtain qualifications to the recurrence. |
paul@68 | 236 | |
paul@68 | 237 | while words.have("of"): |
paul@68 | 238 | current = Selector(current) |
paul@68 | 239 | current = parseRecurrence(words, current) |
paul@68 | 240 | parseOptionalLimits(words, current) |
paul@68 | 241 | |
paul@68 | 242 | if not words.end: |
paul@68 | 243 | raise ParseError, words.tokens |
paul@68 | 244 | |
paul@68 | 245 | return current |
paul@68 | 246 | |
paul@68 | 247 | def parseRecurrence(words, current): |
paul@69 | 248 | |
paul@69 | 249 | """ |
paul@69 | 250 | Using the incoming 'words' and given the 'current' selector, parse a |
paul@69 | 251 | recurrence that can be either a repeating recurrence (starting with "every") |
paul@71 | 252 | or a specific recurrence (starting with "the") or a concrete datetime |
paul@71 | 253 | reference. |
paul@69 | 254 | |
paul@69 | 255 | The active selector is returned as a result of parsing the recurrence. |
paul@69 | 256 | """ |
paul@69 | 257 | |
paul@68 | 258 | words.next() |
paul@68 | 259 | if words.have("every"): |
paul@68 | 260 | parseRepeatingRecurrence(words, current) |
paul@68 | 261 | words.want() |
paul@68 | 262 | return current |
paul@71 | 263 | else: |
paul@68 | 264 | return parseSpecificRecurrence(words, current) |
paul@70 | 265 | |
paul@70 | 266 | def parseConcreteDateTime(words, current): |
paul@70 | 267 | |
paul@70 | 268 | """ |
paul@70 | 269 | Using the incoming 'words' and given the 'current' selector, parse and |
paul@70 | 270 | return a datetime acting as a specific recurrence. |
paul@70 | 271 | """ |
paul@70 | 272 | |
paul@70 | 273 | word = words.have() |
paul@70 | 274 | |
paul@70 | 275 | # Detect dates. |
paul@70 | 276 | |
paul@70 | 277 | date = getDate(word) |
paul@70 | 278 | if date: |
paul@70 | 279 | current.add_datetime("day", intervals["day"], date) |
paul@70 | 280 | words.want() |
paul@70 | 281 | return current |
paul@70 | 282 | |
paul@70 | 283 | # Detect months. |
paul@70 | 284 | |
paul@70 | 285 | month = getMonth(word) |
paul@70 | 286 | if month: |
paul@70 | 287 | current.add_datetime("month", intervals["month"], month) |
paul@70 | 288 | words.want() |
paul@70 | 289 | return current |
paul@70 | 290 | |
paul@70 | 291 | # Detect years. |
paul@70 | 292 | |
paul@70 | 293 | if word.isdigit(): |
paul@70 | 294 | current.add_datetime("year", intervals["year"], int(word)) |
paul@70 | 295 | words.want() |
paul@70 | 296 | return current |
paul@70 | 297 | |
paul@70 | 298 | raise ParseError, words.tokens |
paul@68 | 299 | |
paul@68 | 300 | def parseSpecificRecurrence(words, current): |
paul@69 | 301 | |
paul@69 | 302 | """ |
paul@69 | 303 | Using the incoming 'words' and given the 'current' selector, parse and |
paul@69 | 304 | return a specific recurrence. |
paul@69 | 305 | """ |
paul@69 | 306 | |
paul@71 | 307 | word = words.have() |
paul@71 | 308 | |
paul@71 | 309 | # Detect month labels. |
paul@71 | 310 | |
paul@71 | 311 | if word in month_labels: |
paul@71 | 312 | current.add_details("the", word, month_labels.index(word) + 1, "month", intervals["month"]) |
paul@72 | 313 | words.want() |
paul@71 | 314 | |
paul@69 | 315 | # Handle the qualifier and interval. |
paul@69 | 316 | |
paul@71 | 317 | elif word == "the": |
paul@71 | 318 | qualifier = words.next() |
paul@71 | 319 | qualifier_level = isSpecificQualifier(qualifier) |
paul@71 | 320 | if not qualifier_level: |
paul@71 | 321 | raise ParseError, words.tokens |
paul@71 | 322 | interval = words.next() |
paul@71 | 323 | interval_level = intervals.get(interval) |
paul@71 | 324 | if not interval_level: |
paul@71 | 325 | raise ParseError, words.tokens |
paul@68 | 326 | |
paul@71 | 327 | current.add_details("the", qualifier, qualifier_level, interval, interval_level) |
paul@71 | 328 | |
paul@71 | 329 | # Detect the intervals becoming smaller. |
paul@68 | 330 | |
paul@71 | 331 | if current.qualified_by and current.interval_level < current.qualified_by.interval_level: |
paul@71 | 332 | raise ParseError, words.tokens |
paul@69 | 333 | |
paul@71 | 334 | # Define selectors that are being qualified by the above qualifier and |
paul@71 | 335 | # interval, returning the least specific selector. |
paul@69 | 336 | |
paul@71 | 337 | words.want() |
paul@71 | 338 | if words.have("in"): |
paul@71 | 339 | current = Selector(current) |
paul@71 | 340 | words.want() |
paul@71 | 341 | current = parseSpecificRecurrence(words, current) |
paul@69 | 342 | |
paul@71 | 343 | # Handle concrete datetime details. |
paul@71 | 344 | |
paul@71 | 345 | else: |
paul@71 | 346 | current = parseConcreteDateTime(words, current) |
paul@68 | 347 | |
paul@68 | 348 | return current |
paul@68 | 349 | |
paul@68 | 350 | def parseRepeatingRecurrence(words, current): |
paul@69 | 351 | |
paul@69 | 352 | """ |
paul@69 | 353 | Using the incoming 'words' and given the 'current' selector, parse and |
paul@69 | 354 | return a repeating recurrence. |
paul@69 | 355 | """ |
paul@69 | 356 | |
paul@68 | 357 | qualifier = words.next() |
paul@70 | 358 | |
paul@70 | 359 | # Handle intervals without qualifiers. |
paul@70 | 360 | |
paul@68 | 361 | if intervals.has_key(qualifier): |
paul@68 | 362 | interval = qualifier |
paul@69 | 363 | interval_level = intervals.get(interval) |
paul@68 | 364 | qualifier = "single" |
paul@68 | 365 | qualifier_level = isRepeatingQualifier(qualifier) |
paul@70 | 366 | |
paul@70 | 367 | # Handle qualified intervals. |
paul@70 | 368 | |
paul@68 | 369 | else: |
paul@68 | 370 | qualifier_level = isRepeatingQualifier(qualifier) |
paul@68 | 371 | if not qualifier_level: |
paul@68 | 372 | raise ParseError, words.tokens |
paul@68 | 373 | interval = words.next() |
paul@69 | 374 | interval_level = intervals.get(interval) |
paul@68 | 375 | if not interval_level: |
paul@68 | 376 | raise ParseError, words.tokens |
paul@68 | 377 | |
paul@68 | 378 | current.add_details("every", qualifier, qualifier_level, interval, interval_level) |
paul@68 | 379 | |
paul@69 | 380 | # Detect the intervals becoming smaller. |
paul@69 | 381 | |
paul@69 | 382 | if current.qualified_by and current.interval_level < current.qualified_by.interval_level: |
paul@69 | 383 | raise ParseError, words.tokens |
paul@69 | 384 | |
paul@68 | 385 | def parseOptionalLimits(words, current): |
paul@69 | 386 | |
paul@69 | 387 | """ |
paul@69 | 388 | Using the incoming 'words' and given the 'current' selector, parse any |
paul@69 | 389 | optional limits, where these only apply to repeating occurrences. |
paul@69 | 390 | """ |
paul@69 | 391 | |
paul@68 | 392 | if current.recurrence_type == "every": |
paul@68 | 393 | parseLimits(words, current) |
paul@68 | 394 | |
paul@68 | 395 | def parseLimits(words, current): |
paul@69 | 396 | |
paul@69 | 397 | """ |
paul@69 | 398 | Using the incoming 'words', parse any limits applying to the 'current' |
paul@69 | 399 | selector. |
paul@69 | 400 | """ |
paul@69 | 401 | |
paul@69 | 402 | from_datetime = until_datetime = None |
paul@69 | 403 | |
paul@68 | 404 | if words.have("from"): |
paul@71 | 405 | words.want() |
paul@68 | 406 | from_datetime = Selector() |
paul@71 | 407 | from_datetime = parseSpecificRecurrence(words, from_datetime) |
paul@71 | 408 | |
paul@71 | 409 | # Detect the limit interval differing from the selector. |
paul@71 | 410 | |
paul@71 | 411 | if current.interval_level != from_datetime.get_resolution(): |
paul@71 | 412 | raise ParseError, words.tokens |
paul@71 | 413 | |
paul@68 | 414 | current.set_from(from_datetime) |
paul@68 | 415 | |
paul@68 | 416 | if words.have("until"): |
paul@71 | 417 | words.want() |
paul@68 | 418 | until_datetime = Selector() |
paul@71 | 419 | until_datetime = parseSpecificRecurrence(words, until_datetime) |
paul@71 | 420 | |
paul@71 | 421 | # Detect the limit interval differing from the selector. |
paul@71 | 422 | |
paul@71 | 423 | if current.interval_level != until_datetime.get_resolution(): |
paul@71 | 424 | raise ParseError, words.tokens |
paul@71 | 425 | |
paul@68 | 426 | current.set_until(until_datetime) |
paul@68 | 427 | |
paul@69 | 428 | # Where the selector refers to a interval repeating at a frequency greater |
paul@69 | 429 | # than one, some limit must be specified to "anchor" the occurrences. |
paul@69 | 430 | |
paul@69 | 431 | elif not from_datetime and current.qualifier_level != 1: |
paul@69 | 432 | raise ParseError, words.tokens |
paul@69 | 433 | |
paul@68 | 434 | # vim: tabstop=4 expandtab shiftwidth=4 |