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