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