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