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