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