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