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