1 # -*- coding: iso-8859-1 -*- 2 """ 3 MoinMoin - EventAggregator library 4 5 @copyright: 2008, 2009, 2010 by Paul Boddie <paul@boddie.org.uk> 6 @copyright: 2000-2004 Juergen Hermann <jh@web.de>, 7 2005-2008 MoinMoin:ThomasWaldmann. 8 @license: GNU GPL (v2 or later), see COPYING.txt for details. 9 """ 10 11 from MoinMoin.Page import Page 12 from MoinMoin import search, version 13 from MoinMoin import wikiutil 14 import calendar 15 import datetime 16 import time 17 import re 18 19 try: 20 set 21 except NameError: 22 from sets import Set as set 23 24 try: 25 import pytz 26 except ImportError: 27 pytz = None 28 29 __version__ = "0.7" 30 31 # Date labels. 32 33 month_labels = ["January", "February", "March", "April", "May", "June", 34 "July", "August", "September", "October", "November", "December"] 35 weekday_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 36 37 # Regular expressions where MoinMoin does not provide the required support. 38 39 category_regexp = None 40 41 # Page parsing. 42 43 definition_list_regexp = re.compile(ur'(?P<wholeterm>^(?P<optcomment>#*)\s+(?P<term>.*?):: )(?P<desc>.*?)$', re.UNICODE | re.MULTILINE) 44 category_membership_regexp = re.compile(ur"^\s*((Category\S+)(\s+Category\S+)*)\s*$", re.MULTILINE | re.UNICODE) 45 46 # Value parsing. 47 48 country_code_regexp = re.compile(ur'(?:^|\W)(?P<code>[A-Z]{2})(?:$|\W+$)', re.UNICODE) 49 50 month_regexp_str = ur'(?P<year>[0-9]{4})-(?P<month>[0-9]{2})' 51 date_regexp_str = ur'(?P<year>[0-9]{4})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})' 52 time_regexp_str = ur'(?P<hour>[0-2][0-9]):(?P<minute>[0-5][0-9])(?::(?P<second>[0-6][0-9]))?' 53 timezone_offset_str = ur'(?P<offset>(UTC)?(?:(?P<sign>[-+])(?P<hours>[0-9]{2})(?::?(?P<minutes>[0-9]{2}))?))' 54 timezone_olson_str = ur'(?P<olson>[a-zA-Z]+(?:/[-_a-zA-Z]+){1,2})' 55 timezone_utc_str = ur'UTC' 56 timezone_regexp_str = ur'(?P<zone>' + timezone_offset_str + '|' + timezone_olson_str + '|' + timezone_utc_str + ')' 57 datetime_regexp_str = date_regexp_str + ur'(?:\s+' + time_regexp_str + ur'(?:\s+' + timezone_regexp_str + ur')?)?' 58 59 month_regexp = re.compile(month_regexp_str, re.UNICODE) 60 date_regexp = re.compile(date_regexp_str, re.UNICODE) 61 time_regexp = re.compile(time_regexp_str, re.UNICODE) 62 datetime_regexp = re.compile(datetime_regexp_str, re.UNICODE) 63 timezone_olson_regexp = re.compile(timezone_olson_str, re.UNICODE) 64 timezone_offset_regexp = re.compile(timezone_offset_str, re.UNICODE) 65 66 verbatim_regexp = re.compile(ur'(?:' 67 ur'<<Verbatim\((?P<verbatim>.*?)\)>>' 68 ur'|' 69 ur'\[\[Verbatim\((?P<verbatim2>.*?)\)\]\]' 70 ur'|' 71 ur'`(?P<monospace>.*?)`' 72 ur'|' 73 ur'{{{(?P<preformatted>.*?)}}}' 74 ur')', re.UNICODE) 75 76 # Utility functions. 77 78 def getCategoryPattern(request): 79 global category_regexp 80 81 try: 82 return request.cfg.cache.page_category_regexact 83 except AttributeError: 84 85 # Use regular expression from MoinMoin 1.7.1 otherwise. 86 87 if category_regexp is None: 88 category_regexp = re.compile(u'^%s$' % ur'(?P<all>Category(?P<key>(?!Template)\S+))', re.UNICODE) 89 return category_regexp 90 91 def int_or_none(x): 92 if x is None: 93 return x 94 else: 95 return int(x) 96 97 # Utility classes and associated functions. 98 99 class Form: 100 101 """ 102 A wrapper preserving MoinMoin 1.8.x (and earlier) behaviour in a 1.9.x 103 environment. 104 """ 105 106 def __init__(self, form): 107 self.form = form 108 109 def get(self, name, default=None): 110 values = self.form.getlist(name) 111 if not values: 112 return default 113 else: 114 return values 115 116 def __getitem__(self, name): 117 return self.form.getlist(name) 118 119 class ActionSupport: 120 121 """ 122 Work around disruptive MoinMoin changes in 1.9, and also provide useful 123 convenience methods. 124 """ 125 126 def get_form(self): 127 return get_form(self.request) 128 129 def _get_selected(self, value, input_value): 130 131 """ 132 Return the HTML attribute text indicating selection of an option (or 133 otherwise) if 'value' matches 'input_value'. 134 """ 135 136 return input_value is not None and value == input_value and 'selected="selected"' or '' 137 138 def _get_selected_for_list(self, value, input_values): 139 140 """ 141 Return the HTML attribute text indicating selection of an option (or 142 otherwise) if 'value' matches one of the 'input_values'. 143 """ 144 145 return value in input_values and 'selected="selected"' or '' 146 147 def _get_input(self, form, name, default=None): 148 149 """ 150 Return the input from 'form' having the given 'name', returning either 151 the input converted to an integer or the given 'default' (optional, None 152 if not specified). 153 """ 154 155 value = form.get(name, [None])[0] 156 if not value: # true if 0 obtained 157 return default 158 else: 159 return int(value) 160 161 def get_month_lists(self, default_as_current=0): 162 163 """ 164 Return two lists of HTML element definitions corresponding to the start 165 and end month selection controls, with months selected according to any 166 values that have been specified via request parameters. 167 """ 168 169 _ = self._ 170 form = self.get_form() 171 172 # Initialise month lists. 173 174 start_month_list = [] 175 end_month_list = [] 176 177 start_month = self._get_input(form, "start-month", default_as_current and getCurrentMonth().month() or None) 178 end_month = self._get_input(form, "end-month", start_month) 179 180 # Prepare month lists, selecting specified months. 181 182 if not default_as_current: 183 start_month_list.append('<option value=""></option>') 184 end_month_list.append('<option value=""></option>') 185 186 for month in range(1, 13): 187 month_label = _(getMonthLabel(month)) 188 selected = self._get_selected(month, start_month) 189 start_month_list.append('<option value="%02d" %s>%s</option>' % (month, selected, month_label)) 190 selected = self._get_selected(month, end_month) 191 end_month_list.append('<option value="%02d" %s>%s</option>' % (month, selected, month_label)) 192 193 return start_month_list, end_month_list 194 195 def get_year_defaults(self, default_as_current=0): 196 197 "Return defaults for the start and end years." 198 199 form = self.get_form() 200 201 start_year_default = form.get("start-year", [default_as_current and getCurrentYear() or ""])[0] 202 end_year_default = form.get("end-year", [default_as_current and start_year_default or ""])[0] 203 204 return start_year_default, end_year_default 205 206 def get_form(request): 207 208 "Work around disruptive MoinMoin changes in 1.9." 209 210 if hasattr(request, "values"): 211 return Form(request.values) 212 else: 213 return request.form 214 215 class send_headers: 216 217 """ 218 A wrapper to preserve MoinMoin 1.8.x (and earlier) request behaviour in a 219 1.9.x environment. 220 """ 221 222 def __init__(self, request): 223 self.request = request 224 225 def __call__(self, headers): 226 for header in headers: 227 parts = header.split(":") 228 self.request.headers.add(parts[0], ":".join(parts[1:])) 229 230 # Textual representations. 231 232 def getHTTPTimeString(tmtuple): 233 return "%s, %02d %s %04d %02d:%02d:%02d GMT" % ( 234 weekday_labels[tmtuple.tm_wday], 235 tmtuple.tm_mday, 236 month_labels[tmtuple.tm_mon -1], # zero-based labels 237 tmtuple.tm_year, 238 tmtuple.tm_hour, 239 tmtuple.tm_min, 240 tmtuple.tm_sec 241 ) 242 243 def getSimpleWikiText(text): 244 245 """ 246 Return the plain text representation of the given 'text' which may employ 247 certain Wiki syntax features, such as those providing verbatim or monospaced 248 text. 249 """ 250 251 # NOTE: Re-implementing support for verbatim text and linking avoidance. 252 253 return "".join([s for s in verbatim_regexp.split(text) if s is not None]) 254 255 def getEncodedWikiText(text): 256 257 "Encode the given 'text' in a verbatim representation." 258 259 return "<<Verbatim(%s)>>" % text 260 261 def getPrettyTitle(title): 262 263 "Return a nicely formatted version of the given 'title'." 264 265 return title.replace("_", " ").replace("/", u" ? ") 266 267 def getMonthLabel(month): 268 269 "Return an unlocalised label for the given 'month'." 270 271 return month_labels[month - 1] # zero-based labels 272 273 def getDayLabel(weekday): 274 275 "Return an unlocalised label for the given 'weekday'." 276 277 return weekday_labels[weekday] 278 279 # Action support functions. 280 281 def getPageRevision(page): 282 283 "Return the revision details dictionary for the given 'page'." 284 285 # From Page.edit_info... 286 287 if hasattr(page, "editlog_entry"): 288 line = page.editlog_entry() 289 else: 290 line = page._last_edited(page.request) # MoinMoin 1.5.x and 1.6.x 291 292 # Similar to Page.mtime_usecs behaviour... 293 294 if line: 295 timestamp = line.ed_time_usecs 296 mtime = wikiutil.version2timestamp(long(timestamp)) # must be long for py 2.2.x 297 comment = line.comment 298 else: 299 mtime = 0 300 comment = "" 301 302 return {"timestamp" : time.gmtime(mtime), "comment" : comment} 303 304 # Category discovery and searching. 305 306 def getCategories(request): 307 308 """ 309 From the AdvancedSearch macro, return a list of category page names using 310 the given 'request'. 311 """ 312 313 # This will return all pages with "Category" in the title. 314 315 cat_filter = getCategoryPattern(request).search 316 return request.rootpage.getPageList(filter=cat_filter) 317 318 def getCategoryMapping(category_pagenames, request): 319 320 """ 321 For the given 'category_pagenames' return a list of tuples of the form 322 (category name, category page name) using the given 'request'. 323 """ 324 325 cat_pattern = getCategoryPattern(request) 326 mapping = [] 327 for pagename in category_pagenames: 328 name = cat_pattern.match(pagename).group("key") 329 if name != "Category": 330 mapping.append((name, pagename)) 331 mapping.sort() 332 return mapping 333 334 def getCategoryPages(pagename, request): 335 336 """ 337 Return the pages associated with the given category 'pagename' using the 338 'request'. 339 """ 340 341 query = search.QueryParser().parse_query('category:%s' % pagename) 342 results = search.searchPages(request, query, "page_name") 343 344 cat_pattern = getCategoryPattern(request) 345 pages = [] 346 for page in results.hits: 347 if not cat_pattern.match(page.page_name): 348 pages.append(page) 349 return pages 350 351 # The main activity functions. 352 353 class EventPage: 354 355 "An event page." 356 357 def __init__(self, page): 358 self.page = page 359 self.events = None 360 self.body = None 361 self.categories = None 362 363 def copyPage(self, page): 364 365 "Copy the body of the given 'page'." 366 367 self.body = page.getBody() 368 369 def getPageURL(self, request): 370 371 "Using 'request', return the URL of this page." 372 373 return request.getQualifiedURL(self.page.url(request, relative=0)) 374 375 def getFormat(self): 376 377 "Get the format used on this page." 378 379 return self.page.pi["format"] 380 381 def getRevisions(self): 382 383 "Return a list of page revisions." 384 385 return self.page.getRevList() 386 387 def getPageRevision(self): 388 389 "Return the revision details dictionary for this page." 390 391 return getPageRevision(self.page) 392 393 def getPageName(self): 394 395 "Return the page name." 396 397 return self.page.page_name 398 399 def getPrettyPageName(self): 400 401 "Return a nicely formatted title/name for this page." 402 403 return getPrettyPageName(self.page) 404 405 def getBody(self): 406 407 "Get the current page body." 408 409 if self.body is None: 410 self.body = self.page.get_raw_body() 411 return self.body 412 413 def getEvents(self): 414 415 "Return a list of events from this page." 416 417 if self.events is None: 418 details = {} 419 self.events = [Event(self, details)] 420 421 if self.getFormat() == "wiki": 422 for match in definition_list_regexp.finditer(self.getBody()): 423 424 # Skip commented-out items. 425 426 if match.group("optcomment"): 427 continue 428 429 # Permit case-insensitive list terms. 430 431 term = match.group("term").lower() 432 desc = match.group("desc") 433 434 # Special value type handling. 435 436 # Dates. 437 438 if term in ("start", "end"): 439 desc = getDate(desc) 440 441 # Lists (whose elements may be quoted). 442 443 elif term in ("topics", "categories"): 444 desc = [getSimpleWikiText(value.strip()) for value in desc.split(",") if value.strip()] 445 446 # Labels which may well be quoted. 447 448 elif term in ("title", "summary", "description", "location"): 449 desc = getSimpleWikiText(desc) 450 451 if desc is not None: 452 453 # Handle apparent duplicates by creating a new set of 454 # details. 455 456 if details.has_key(term): 457 458 # Make a new event. 459 460 details = {} 461 self.events.append(Event(self, details)) 462 463 details[term] = desc 464 465 return self.events 466 467 def setEvents(self, events): 468 469 "Set the given 'events' on this page." 470 471 self.events = events 472 473 def getCategoryMembership(self): 474 475 "Get the category names from this page." 476 477 if self.categories is None: 478 body = self.getBody() 479 match = category_membership_regexp.search(body) 480 self.categories = match.findall().split() 481 482 return self.categories 483 484 def setCategoryMembership(self, category_names): 485 486 """ 487 Set the category membership for the page using the specified 488 'category_names'. 489 """ 490 491 self.categories = category_names 492 493 def flushEventDetails(self): 494 495 "Flush the current event details to this page's body text." 496 497 new_body_parts = [] 498 end_of_last_match = 0 499 body = self.getBody() 500 501 events = iter(self.getEvents()) 502 503 event = events.next() 504 event_details = event.getDetails() 505 replaced_terms = set() 506 507 for match in definition_list_regexp.finditer(body): 508 509 # Permit case-insensitive list terms. 510 511 term = match.group("term").lower() 512 desc = match.group("desc") 513 514 # Check that the term has not already been substituted. If so, 515 # get the next event. 516 517 if term in replaced_terms: 518 try: 519 event = events.next() 520 521 # No more events. 522 523 except StopIteration: 524 break 525 526 event_details = event.getDetails() 527 replaced_terms = set() 528 529 # Add preceding text to the new body. 530 531 new_body_parts.append(body[end_of_last_match:match.start()]) 532 533 # Get the matching regions, adding the term to the new body. 534 535 new_body_parts.append(match.group("wholeterm")) 536 537 # Special value type handling. 538 539 if event_details.has_key(term): 540 541 # Dates. 542 543 if term in ("start", "end"): 544 desc = desc.replace("YYYY-MM-DD", str(event_details[term])) 545 546 # Lists (whose elements may be quoted). 547 548 elif term in ("topics", "categories"): 549 desc = ", ".join([getEncodedWikiText(item) for item in event_details[term]]) 550 551 # Labels which must be quoted. 552 553 elif term in ("title", "summary"): 554 desc = getEncodedWikiText(event_details[term]) 555 556 # Text which need not be quoted, but it will be Wiki text. 557 558 elif term in ("description", "link", "location"): 559 desc = event_details[term] 560 561 replaced_terms.add(term) 562 563 # Add the replaced value. 564 565 new_body_parts.append(desc) 566 567 # Remember where in the page has been processed. 568 569 end_of_last_match = match.end() 570 571 # Write the rest of the page. 572 573 new_body_parts.append(body[end_of_last_match:]) 574 575 self.body = "".join(new_body_parts) 576 577 def flushCategoryMembership(self): 578 579 "Flush the category membership to the page body." 580 581 body = self.getBody() 582 category_names = self.getCategoryMembership() 583 match = category_membership_regexp.search(body) 584 585 if match: 586 self.body = "".join([body[:match.start()], " ".join(category_names), body[match.end():]]) 587 588 def saveChanges(self): 589 590 "Save changes to the event." 591 592 self.flushEventDetails() 593 self.flushCategoryMembership() 594 self.page.saveText(self.getBody(), 0) 595 596 def linkToPage(self, request, text, query_string=None): 597 598 """ 599 Using 'request', return a link to this page with the given link 'text' 600 and optional 'query_string'. 601 """ 602 603 return linkToPage(request, self.page, text, query_string) 604 605 class Event: 606 607 "A description of an event." 608 609 def __init__(self, page, details): 610 self.page = page 611 self.details = details 612 613 def __cmp__(self, other): 614 615 """ 616 Compare this object with 'other' using the event start and end details. 617 """ 618 619 details1 = self.details 620 details2 = other.details 621 return cmp( 622 (details1["start"], details1["end"]), 623 (details2["start"], details2["end"]) 624 ) 625 626 def getPage(self): 627 628 "Return the page describing this event." 629 630 return self.page 631 632 def setPage(self, page): 633 634 "Set the 'page' describing this event." 635 636 self.page = page 637 638 def getSummary(self, event_parent=None): 639 640 """ 641 Return either the given title or summary of the event according to the 642 event details, or a summary made from using the pretty version of the 643 page name. 644 645 If the optional 'event_parent' is specified, any page beneath the given 646 'event_parent' page in the page hierarchy will omit this parent information 647 if its name is used as the summary. 648 """ 649 650 event_details = self.details 651 652 if event_details.has_key("title"): 653 return event_details["title"] 654 elif event_details.has_key("summary"): 655 return event_details["summary"] 656 else: 657 # If appropriate, remove the parent details and "/" character. 658 659 title = self.page.getPageName() 660 661 if event_parent and title.startswith(event_parent): 662 title = title[len(event_parent.rstrip("/")) + 1:] 663 664 return getPrettyTitle(title) 665 666 def getDetails(self): 667 668 "Return the details for this event." 669 670 return self.details 671 672 def setDetails(self, event_details): 673 674 "Set the 'event_details' for this event." 675 676 self.details = event_details 677 678 def getEvents(request, category_names, calendar_start=None, calendar_end=None): 679 680 """ 681 Using the 'request', generate a list of events found on pages belonging to 682 the specified 'category_names', using the optional 'calendar_start' and 683 'calendar_end' month tuples of the form (year, month) to indicate a window 684 of interest. 685 686 Return a list of events, a dictionary mapping months to event lists (within 687 the window of interest), a list of all events within the window of interest, 688 the earliest month of an event within the window of interest, and the latest 689 month of an event within the window of interest. 690 """ 691 692 # Re-order the window, if appropriate. 693 694 if calendar_start is not None and calendar_end is not None and calendar_start > calendar_end: 695 calendar_start, calendar_end = calendar_end, calendar_start 696 697 events = [] 698 shown_events = {} 699 all_shown_events = [] 700 processed_pages = set() 701 702 earliest = None 703 latest = None 704 705 for category_name in category_names: 706 707 # Get the pages and page names in the category. 708 709 pages_in_category = getCategoryPages(category_name, request) 710 711 # Visit each page in the category. 712 713 for page_in_category in pages_in_category: 714 pagename = page_in_category.page_name 715 716 # Only process each page once. 717 718 if pagename in processed_pages: 719 continue 720 else: 721 processed_pages.add(pagename) 722 723 # Get a real page, not a result page. 724 725 event_page = EventPage(Page(request, pagename)) 726 727 # Get all events described in the page. 728 729 for event in event_page.getEvents(): 730 event_details = event.getDetails() 731 732 # Remember the event. 733 734 events.append(event) 735 736 # Test for the suitability of the event. 737 738 if event_details.has_key("start") and event_details.has_key("end"): 739 740 start_month = event_details["start"].as_month() 741 end_month = event_details["end"].as_month() 742 743 # Compare the months of the dates to the requested calendar 744 # window, if any. 745 746 if (calendar_start is None or end_month >= calendar_start) and \ 747 (calendar_end is None or start_month <= calendar_end): 748 749 all_shown_events.append(event) 750 751 if earliest is None or start_month < earliest: 752 earliest = start_month 753 if latest is None or end_month > latest: 754 latest = end_month 755 756 # Store the event in the month-specific dictionary. 757 758 first = max(start_month, calendar_start or start_month) 759 last = min(end_month, calendar_end or end_month) 760 761 for event_month in first.months_until(last): 762 if not shown_events.has_key(event_month): 763 shown_events[event_month] = [] 764 shown_events[event_month].append(event) 765 766 return events, shown_events, all_shown_events, earliest, latest 767 768 def setEventTimestamps(request, events): 769 770 """ 771 Using 'request', set timestamp details in the details dictionary of each of 772 the 'events'. 773 774 Return the latest timestamp found. 775 """ 776 777 latest = None 778 779 for event in events: 780 event_details = event.getDetails() 781 event_page = event.getPage() 782 783 # Get the initial revision of the page. 784 785 revisions = event_page.getRevisions() 786 event_page_initial = Page(request, event_page.getPageName(), rev=revisions[-1]) 787 788 # Get the created and last modified times. 789 790 initial_revision = getPageRevision(event_page_initial) 791 event_details["created"] = initial_revision["timestamp"] 792 latest_revision = event_page.getPageRevision() 793 event_details["last-modified"] = latest_revision["timestamp"] 794 event_details["sequence"] = len(revisions) - 1 795 event_details["last-comment"] = latest_revision["comment"] 796 797 if latest is None or latest < event_details["last-modified"]: 798 latest = event_details["last-modified"] 799 800 return latest 801 802 def getOrderedEvents(events): 803 804 """ 805 Return a list with the given 'events' ordered according to their start and 806 end dates. 807 """ 808 809 ordered_events = events[:] 810 ordered_events.sort() 811 return ordered_events 812 813 def getConcretePeriod(calendar_start, calendar_end, earliest, latest): 814 815 """ 816 From the requested 'calendar_start' and 'calendar_end', which may be None, 817 indicating that no restriction is imposed on the period for each of the 818 boundaries, use the 'earliest' and 'latest' event months to define a 819 specific period of interest. 820 """ 821 822 # Define the period as starting with any specified start month or the 823 # earliest event known, ending with any specified end month or the latest 824 # event known. 825 826 first = calendar_start or earliest 827 last = calendar_end or latest 828 829 # If there is no range of months to show, perhaps because there are no 830 # events in the requested period, and there was no start or end month 831 # specified, show only the month indicated by the start or end of the 832 # requested period. If all events were to be shown but none were found show 833 # the current month. 834 835 if first is None: 836 first = last or getCurrentMonth() 837 if last is None: 838 last = first or getCurrentMonth() 839 840 # Permit "expiring" periods (where the start date approaches the end date). 841 842 return min(first, last), last 843 844 def getCoverage(start, end, events): 845 846 """ 847 Within the period defined by the 'start' and 'end' dates, determine the 848 coverage of the days in the period by the given 'events', returning a set of 849 covered days, along with a list of slots, where each slot contains a tuple 850 of the form (set of covered days, events). 851 """ 852 853 all_events = [] 854 full_coverage = set() 855 856 # Get event details. 857 858 for event in events: 859 event_details = event.getDetails() 860 861 # Test for the event in the period. 862 863 if event_details["start"] <= end and event_details["end"] >= start: 864 865 # Find the coverage of this period for the event. 866 867 event_start = max(event_details["start"], start) 868 event_end = min(event_details["end"], end) 869 event_coverage = set(event_start.days_until(event_end)) 870 871 # Update the overall coverage. 872 873 full_coverage.update(event_coverage) 874 875 # Try and fit the event into the events list. 876 877 for i, (coverage, covered_events) in enumerate(all_events): 878 879 # Where the event does not overlap with the current 880 # element, add it alongside existing events. 881 882 if not coverage.intersection(event_coverage): 883 covered_events.append(event) 884 all_events[i] = coverage.union(event_coverage), covered_events 885 break 886 887 # Make a new element in the list if the event cannot be 888 # marked alongside existing events. 889 890 else: 891 all_events.append((event_coverage, [event])) 892 893 return full_coverage, all_events 894 895 # Date-related functions. 896 897 class Period: 898 899 "A simple period of time." 900 901 def __init__(self, data): 902 self.data = data 903 904 def months(self): 905 return self.data[0] * 12 + self.data[1] 906 907 class Temporal: 908 909 "A simple temporal representation, common to dates and times." 910 911 def __init__(self, data): 912 self.data = list(data) 913 914 def __repr__(self): 915 return "%s(%r)" % (self.__class__.__name__, self.data) 916 917 def __hash__(self): 918 return hash(self.as_tuple()) 919 920 def as_tuple(self): 921 return tuple(self.data) 922 923 def __cmp__(self, other): 924 data = self.as_tuple() 925 other_data = other.as_tuple() 926 length = min(len(data), len(other_data)) 927 return cmp(self.data[:length], other.data[:length]) 928 929 def until(self, start, end, nextfn, prevfn): 930 931 """ 932 Return a collection of units of time by starting from the given 'start' 933 and stepping across intervening units until 'end' is reached, using the 934 given 'nextfn' and 'prevfn' to step from one unit to the next. 935 """ 936 937 current = start 938 units = [current] 939 if current < end: 940 while current < end: 941 current = nextfn(current) 942 units.append(current) 943 elif current > end: 944 while current > end: 945 current = prevfn(current) 946 units.append(current) 947 return units 948 949 class Month(Temporal): 950 951 "A simple year-month representation." 952 953 def __str__(self): 954 return "%04d-%02d" % self.as_tuple()[:2] 955 956 def as_datetime(self, day, hour, minute, second, zone): 957 return DateTime(self.as_tuple() + (day, hour, minute, second, zone)) 958 959 def as_date(self, day): 960 return Date(self.as_tuple() + (day,)) 961 962 def as_month(self): 963 return self 964 965 def year(self): 966 return self.data[0] 967 968 def month(self): 969 return self.data[1] 970 971 def month_properties(self): 972 973 """ 974 Return the weekday of the 1st of the month, along with the number of 975 days, as a tuple. 976 """ 977 978 year, month = self.as_tuple()[:2] 979 return calendar.monthrange(year, month) 980 981 def month_update(self, n=1): 982 983 "Return the month updated by 'n' months." 984 985 year, month = self.as_tuple()[:2] 986 return Month((year + (month - 1 + n) / 12, (month - 1 + n) % 12 + 1)) 987 988 def next_month(self): 989 990 "Return the month following this one." 991 992 return self.month_update(1) 993 994 def previous_month(self): 995 996 "Return the month preceding this one." 997 998 return self.month_update(-1) 999 1000 def __sub__(self, start): 1001 1002 """ 1003 Return the difference in years and months between this month and the 1004 'start' month as a period. 1005 """ 1006 1007 return Period([(x - y) for x, y in zip(self.data, start.data)]) 1008 1009 def months_until(self, end): 1010 1011 "Return the collection of months from this month until 'end'." 1012 1013 return self.until(self.as_month(), end.as_month(), Month.next_month, Month.previous_month) 1014 1015 class Date(Month): 1016 1017 "A simple year-month-day representation." 1018 1019 def constrain(self): 1020 year, month, day = self.as_tuple()[:3] 1021 1022 month = max(min(month, 12), 1) 1023 wd, last_day = calendar.monthrange(year, month) 1024 day = max(min(day, last_day), 1) 1025 1026 self.data[1:3] = month, day 1027 1028 def __str__(self): 1029 return "%04d-%02d-%02d" % self.as_tuple()[:3] 1030 1031 def as_datetime(self, hour, minute, second, zone): 1032 return DateTime(self.as_tuple() + (hour, minute, second, zone)) 1033 1034 def as_date(self): 1035 return self 1036 1037 def as_month(self): 1038 return Month(self.data[:2]) 1039 1040 def day(self): 1041 return self.data[2] 1042 1043 def next_day(self): 1044 1045 "Return the date following this one." 1046 1047 year, month, day = self.as_tuple()[:3] 1048 _wd, end_day = calendar.monthrange(year, month) 1049 if day == end_day: 1050 if month == 12: 1051 return Date((year + 1, 1, 1)) 1052 else: 1053 return Date((year, month + 1, 1)) 1054 else: 1055 return Date((year, month, day + 1)) 1056 1057 def previous_day(self): 1058 1059 "Return the date preceding this one." 1060 1061 year, month, day = self.as_tuple()[:3] 1062 if day == 1: 1063 if month == 1: 1064 return Date((year - 1, 12, 31)) 1065 else: 1066 _wd, end_day = calendar.monthrange(year, month - 1) 1067 return Date((year, month - 1, end_day)) 1068 else: 1069 return Date((year, month, day - 1)) 1070 1071 def days_until(self, end): 1072 1073 "Return the collection of days from this date until 'end'." 1074 1075 return self.until(self.as_date(), end.as_date(), Date.next_day, Date.previous_day) 1076 1077 class DateTime(Date): 1078 1079 "A simple date plus time representation." 1080 1081 def constrain(self): 1082 Date.constrain(self) 1083 1084 hour, minute, second = self.as_tuple()[3:6] 1085 1086 if self.has_time(): 1087 hour = max(min(hour, 23), 0) 1088 minute = max(min(minute, 59), 0) 1089 1090 if second is not None: 1091 second = max(min(second, 60), 0) # support leap seconds 1092 1093 self.data[3:6] = hour, minute, second 1094 1095 def __str__(self): 1096 if self.has_time(): 1097 data = self.as_tuple() 1098 time_str = " %02d:%02d" % data[3:5] 1099 if data[5] is not None: 1100 time_str += ":%02d" % data[5] 1101 if data[6] is not None: 1102 time_str += " %s" % data[6] 1103 else: 1104 time_str = "" 1105 1106 return Date.__str__(self) + time_str 1107 1108 def as_datetime(self): 1109 return self 1110 1111 def as_date(self): 1112 return Date(self.data[:3]) 1113 1114 def has_time(self): 1115 return self.data[3] is not None and self.data[4] is not None 1116 1117 def seconds(self): 1118 return self.data[5] 1119 1120 def time_zone(self): 1121 return self.data[6] 1122 1123 def set_time_zone(self, value): 1124 self.data[6] = value 1125 1126 def padded(self): 1127 1128 "Return a datetime with missing fields defined as being zero." 1129 1130 data = map(lambda x: x or 0, self.data[:6]) + self.data[6:] 1131 return DateTime(data) 1132 1133 def to_utc(self): 1134 1135 """ 1136 Return this object converted to UTC, or None if such a conversion is not 1137 defined. 1138 """ 1139 1140 offset = self.utc_offset() 1141 if offset: 1142 hours, minutes = offset 1143 1144 # Invert the offset to get the correction. 1145 1146 hours, minutes = -hours, -minutes 1147 1148 # Get the components. 1149 1150 hour, minute, second, zone = self.as_tuple()[3:] 1151 date = self.as_date() 1152 1153 # Add the minutes and hours. 1154 1155 minute += minutes 1156 if minute < 0 or minute > 59: 1157 hour += minute / 60 1158 minute = minute % 60 1159 1160 # NOTE: This makes various assumptions and probably would not work 1161 # NOTE: for general arithmetic. 1162 1163 hour += hours 1164 if hour < 0: 1165 date = date.previous_day() 1166 hour += 24 1167 elif hour > 23: 1168 date = date.next_day() 1169 hour -= 24 1170 1171 return date.as_datetime(hour, minute, second, "UTC") 1172 1173 # Cannot convert. 1174 1175 else: 1176 return None 1177 1178 def utc_offset(self): 1179 1180 "Return the UTC offset in hours and minutes." 1181 1182 zone = self.time_zone() 1183 if not zone: 1184 return None 1185 1186 # Support explicit UTC zones. 1187 1188 if zone == "UTC": 1189 return 0, 0 1190 1191 # Attempt to return a UTC offset where an explicit offset has been set. 1192 1193 match = timezone_offset_regexp.match(zone) 1194 if match: 1195 if match.group("sign") == "-": 1196 sign = -1 1197 else: 1198 sign = 1 1199 1200 hours = int(match.group("hours")) * sign 1201 minutes = int(match.group("minutes") or 0) * sign 1202 return hours, minutes 1203 1204 # Attempt to handle Olson time zone identifiers. 1205 1206 dt = self.as_olson_datetime() 1207 if dt: 1208 seconds = dt.utcoffset().seconds 1209 hours = seconds / 3600 1210 minutes = (seconds % 3600) / 60 1211 return hours, minutes 1212 1213 # Otherwise return None. 1214 1215 return None 1216 1217 def olson_identifier(self): 1218 1219 "Return the Olson identifier from any zone information." 1220 1221 zone = self.time_zone() 1222 if not zone: 1223 return None 1224 1225 # Attempt to match an identifier. 1226 1227 match = timezone_olson_regexp.match(zone) 1228 if match: 1229 return match.group("olson") 1230 else: 1231 return None 1232 1233 def _as_olson_datetime(self, hours=None): 1234 1235 """ 1236 Return a Python datetime object for this datetime interpreted using any 1237 Olson time zone identifier and the given 'hours' offset, raising one of 1238 the pytz exceptions in case of ambiguity. 1239 """ 1240 1241 olson = self.olson_identifier() 1242 if olson and pytz: 1243 tz = pytz.timezone(olson) 1244 data = self.padded().as_tuple()[:6] 1245 dt = datetime.datetime(*data) 1246 1247 # With an hours offset, find a time probably in a previously 1248 # applicable time zone. 1249 1250 if hours is not None: 1251 td = datetime.timedelta(0, hours * 3600) 1252 dt += td 1253 1254 ldt = tz.localize(dt, None) 1255 1256 # With an hours offset, adjust the time to define it within the 1257 # previously applicable time zone but at the presumably intended 1258 # position. 1259 1260 if hours is not None: 1261 ldt -= td 1262 1263 return ldt 1264 else: 1265 return None 1266 1267 def as_olson_datetime(self): 1268 1269 """ 1270 Return a Python datetime object for this datetime interpreted using any 1271 Olson time zone identifier, choosing the time from the zone before the 1272 period of ambiguity. 1273 """ 1274 1275 try: 1276 return self._as_olson_datetime() 1277 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1278 1279 # Try again, using an earlier local time and then stepping forward 1280 # in the chosen zone. 1281 # NOTE: Four hours earlier seems reasonable. 1282 1283 return self._as_olson_datetime(-4) 1284 1285 def ambiguous(self): 1286 1287 "Return whether the time is local and ambiguous." 1288 1289 try: 1290 self._as_olson_datetime() 1291 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1292 return 1 1293 1294 return 0 1295 1296 def getCountry(s): 1297 1298 "Find a country code in the given string 's'." 1299 1300 match = country_code_regexp.search(s) 1301 1302 if match: 1303 return match.group("code") 1304 else: 1305 return None 1306 1307 def getDate(s): 1308 1309 "Parse the string 's', extracting and returning a datetime object." 1310 1311 m = datetime_regexp.search(s) 1312 if m: 1313 groups = list(m.groups()) 1314 1315 # Convert date and time data to integer or None. 1316 1317 return DateTime(map(int_or_none, groups[:6]) + [m.group("zone")]) 1318 else: 1319 return None 1320 1321 def getDateStrings(s): 1322 1323 "Parse the string 's', extracting and returning all date strings." 1324 1325 start = 0 1326 m = date_regexp.search(s, start) 1327 l = [] 1328 while m: 1329 l.append("-".join(m.groups())) 1330 m = date_regexp.search(s, m.end()) 1331 return l 1332 1333 def getMonth(s): 1334 1335 "Parse the string 's', extracting and returning a month object." 1336 1337 m = month_regexp.search(s) 1338 if m: 1339 return Month(map(int, m.groups())) 1340 else: 1341 return None 1342 1343 def getCurrentMonth(): 1344 1345 "Return the current month as a (year, month) tuple." 1346 1347 today = datetime.date.today() 1348 return Month((today.year, today.month)) 1349 1350 def getCurrentYear(): 1351 1352 "Return the current year." 1353 1354 today = datetime.date.today() 1355 return today.year 1356 1357 # User interface functions. 1358 1359 def getParameter(request, name, default=None): 1360 1361 """ 1362 Using the given 'request', return the value of the parameter with the given 1363 'name', returning the optional 'default' (or None) if no value was supplied 1364 in the 'request'. 1365 """ 1366 1367 return get_form(request).get(name, [default])[0] 1368 1369 def getQualifiedParameter(request, calendar_name, argname, default=None): 1370 1371 """ 1372 Using the given 'request', 'calendar_name' and 'argname', retrieve the 1373 value of the qualified parameter, returning the optional 'default' (or None) 1374 if no value was supplied in the 'request'. 1375 """ 1376 1377 argname = getQualifiedParameterName(calendar_name, argname) 1378 return getParameter(request, argname, default) 1379 1380 def getQualifiedParameterName(calendar_name, argname): 1381 1382 """ 1383 Return the qualified parameter name using the given 'calendar_name' and 1384 'argname'. 1385 """ 1386 1387 if calendar_name is None: 1388 return argname 1389 else: 1390 return "%s-%s" % (calendar_name, argname) 1391 1392 def getParameterMonth(arg): 1393 1394 "Interpret 'arg', recognising keywords and simple arithmetic operations." 1395 1396 n = None 1397 1398 if arg.startswith("current"): 1399 date = getCurrentMonth() 1400 if len(arg) > 8: 1401 n = int(arg[7:]) 1402 1403 elif arg.startswith("yearstart"): 1404 date = Month((getCurrentYear(), 1)) 1405 if len(arg) > 10: 1406 n = int(arg[9:]) 1407 1408 elif arg.startswith("yearend"): 1409 date = Month((getCurrentYear(), 12)) 1410 if len(arg) > 8: 1411 n = int(arg[7:]) 1412 1413 else: 1414 date = getMonth(arg) 1415 1416 if n is not None: 1417 date = date.month_update(n) 1418 1419 return date 1420 1421 def getFormMonth(request, calendar_name, argname): 1422 1423 """ 1424 Return the month from the 'request' for the calendar with the given 1425 'calendar_name' using the parameter having the given 'argname'. 1426 """ 1427 1428 arg = getQualifiedParameter(request, calendar_name, argname) 1429 if arg is not None: 1430 return getParameterMonth(arg) 1431 else: 1432 return None 1433 1434 def getFormMonthPair(request, yeararg, montharg): 1435 1436 """ 1437 Return the month from the 'request' for the calendar with the given 1438 'calendar_name' using the parameters having the given 'yeararg' and 1439 'montharg' names. 1440 """ 1441 1442 year = getParameter(request, yeararg) 1443 month = getParameter(request, montharg) 1444 if year and month: 1445 return Month((int(year), int(month))) 1446 else: 1447 return None 1448 1449 def getFullMonthLabel(request, year_month): 1450 1451 """ 1452 Return the full month plus year label using the given 'request' and 1453 'year_month'. 1454 """ 1455 1456 _ = request.getText 1457 year, month = year_month.as_tuple() 1458 month_label = _(getMonthLabel(month)) 1459 return "%s %s" % (month_label, year) 1460 1461 # Page-related functions. 1462 1463 def getPrettyPageName(page): 1464 1465 "Return a nicely formatted title/name for the given 'page'." 1466 1467 title = page.split_title(force=1) 1468 return getPrettyTitle(title) 1469 1470 def linkToPage(request, page, text, query_string=None): 1471 1472 """ 1473 Using 'request', return a link to 'page' with the given link 'text' and 1474 optional 'query_string'. 1475 """ 1476 1477 text = wikiutil.escape(text) 1478 return page.link_to_raw(request, text, query_string) 1479 1480 def getFullPageName(parent, title): 1481 1482 """ 1483 Return a full page name from the given 'parent' page (can be empty or None) 1484 and 'title' (a simple page name). 1485 """ 1486 1487 if parent: 1488 return "%s/%s" % (parent.rstrip("/"), title) 1489 else: 1490 return title 1491 1492 def fillEventPageFromTemplate(template_page, new_page, event_details, category_pagenames): 1493 1494 """ 1495 Using the given 'template_page', complete the 'new_page' by copying the 1496 template and adding the given 'event_details' (a dictionary of event 1497 fields), setting also the 'category_pagenames' to define category 1498 membership. 1499 """ 1500 1501 event_page = EventPage(template_page) 1502 new_event_page = EventPage(new_page) 1503 new_event_page.copyPage(event_page) 1504 1505 if new_event_page.getFormat() == "wiki": 1506 new_event = Event(new_event_page, event_details) 1507 new_event_page.setEvents([new_event]) 1508 new_event_page.setCategoryMembership(category_pagenames) 1509 new_event_page.saveChanges() 1510 1511 # vim: tabstop=4 expandtab shiftwidth=4