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