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 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 times = set() 947 for timespan in coverage: 948 start, end = timespan.as_times() 949 times.add(start) 950 times.add(end) 951 times = list(times) 952 times.sort() 953 954 scale = [] 955 first = 1 956 start = None 957 for time in times: 958 if not first: 959 scale.append(Timespan(start, time)) 960 else: 961 first = 0 962 start = time 963 return scale 964 965 # Date-related functions. 966 967 class Period: 968 969 "A simple period of time." 970 971 def __init__(self, data): 972 self.data = data 973 974 def months(self): 975 return self.data[0] * 12 + self.data[1] 976 977 class Temporal: 978 979 "A simple temporal representation, common to dates and times." 980 981 def __init__(self, data): 982 self.data = list(data) 983 984 def __repr__(self): 985 return "%s(%r)" % (self.__class__.__name__, self.data) 986 987 def __hash__(self): 988 return hash(self.as_tuple()) 989 990 def as_tuple(self): 991 return tuple(self.data) 992 993 def __cmp__(self, other): 994 if not isinstance(other, Temporal): 995 return NotImplemented 996 else: 997 data = self.as_tuple() 998 other_data = other.as_tuple() 999 length = min(len(data), len(other_data)) 1000 return cmp(data[:length], other_data[:length]) 1001 1002 def until(self, start, end, nextfn, prevfn): 1003 1004 """ 1005 Return a collection of units of time by starting from the given 'start' 1006 and stepping across intervening units until 'end' is reached, using the 1007 given 'nextfn' and 'prevfn' to step from one unit to the next. 1008 """ 1009 1010 current = start 1011 units = [current] 1012 if current < end: 1013 while current < end: 1014 current = nextfn(current) 1015 units.append(current) 1016 elif current > end: 1017 while current > end: 1018 current = prevfn(current) 1019 units.append(current) 1020 return units 1021 1022 class Month(Temporal): 1023 1024 "A simple year-month representation." 1025 1026 def __str__(self): 1027 return "%04d-%02d" % self.as_tuple()[:2] 1028 1029 def as_datetime(self, day, hour, minute, second, zone): 1030 return DateTime(self.as_tuple() + (day, hour, minute, second, zone)) 1031 1032 def as_date(self, day): 1033 return Date(self.as_tuple() + (day,)) 1034 1035 def as_month(self): 1036 return self 1037 1038 def year(self): 1039 return self.data[0] 1040 1041 def month(self): 1042 return self.data[1] 1043 1044 def month_properties(self): 1045 1046 """ 1047 Return the weekday of the 1st of the month, along with the number of 1048 days, as a tuple. 1049 """ 1050 1051 year, month = self.as_tuple()[:2] 1052 return calendar.monthrange(year, month) 1053 1054 def month_update(self, n=1): 1055 1056 "Return the month updated by 'n' months." 1057 1058 year, month = self.as_tuple()[:2] 1059 return Month((year + (month - 1 + n) / 12, (month - 1 + n) % 12 + 1)) 1060 1061 def next_month(self): 1062 1063 "Return the month following this one." 1064 1065 return self.month_update(1) 1066 1067 def previous_month(self): 1068 1069 "Return the month preceding this one." 1070 1071 return self.month_update(-1) 1072 1073 def __sub__(self, start): 1074 1075 """ 1076 Return the difference in years and months between this month and the 1077 'start' month as a period. 1078 """ 1079 1080 return Period([(x - y) for x, y in zip(self.data, start.data)]) 1081 1082 def months_until(self, end): 1083 1084 "Return the collection of months from this month until 'end'." 1085 1086 return self.until(self.as_month(), end.as_month(), Month.next_month, Month.previous_month) 1087 1088 class Date(Month): 1089 1090 "A simple year-month-day representation." 1091 1092 def constrain(self): 1093 year, month, day = self.as_tuple()[:3] 1094 1095 month = max(min(month, 12), 1) 1096 wd, last_day = calendar.monthrange(year, month) 1097 day = max(min(day, last_day), 1) 1098 1099 self.data[1:3] = month, day 1100 1101 def __str__(self): 1102 return "%04d-%02d-%02d" % self.as_tuple()[:3] 1103 1104 def as_datetime(self, hour, minute, second, zone): 1105 return DateTime(self.as_tuple() + (hour, minute, second, zone)) 1106 1107 def as_date(self): 1108 return self 1109 1110 def as_month(self): 1111 return Month(self.data[:2]) 1112 1113 def day(self): 1114 return self.data[2] 1115 1116 def day_update(self, n=1): 1117 1118 "Return the month updated by 'n' months." 1119 1120 delta = datetime.timedelta(n) 1121 dt = datetime.date(*self.as_tuple()[:3]) 1122 dt_new = dt + delta 1123 return Date((dt_new.year, dt_new.month, dt_new.day)) 1124 1125 def next_day(self): 1126 1127 "Return the date following this one." 1128 1129 year, month, day = self.as_tuple()[:3] 1130 _wd, end_day = calendar.monthrange(year, month) 1131 if day == end_day: 1132 if month == 12: 1133 return Date((year + 1, 1, 1)) 1134 else: 1135 return Date((year, month + 1, 1)) 1136 else: 1137 return Date((year, month, day + 1)) 1138 1139 def previous_day(self): 1140 1141 "Return the date preceding this one." 1142 1143 year, month, day = self.as_tuple()[:3] 1144 if day == 1: 1145 if month == 1: 1146 return Date((year - 1, 12, 31)) 1147 else: 1148 _wd, end_day = calendar.monthrange(year, month - 1) 1149 return Date((year, month - 1, end_day)) 1150 else: 1151 return Date((year, month, day - 1)) 1152 1153 def days_until(self, end): 1154 1155 "Return the collection of days from this date until 'end'." 1156 1157 return self.until(self.as_date(), end.as_date(), Date.next_day, Date.previous_day) 1158 1159 class DateTime(Date): 1160 1161 "A simple date plus time representation." 1162 1163 def constrain(self): 1164 Date.constrain(self) 1165 1166 hour, minute, second = self.as_tuple()[3:6] 1167 1168 if self.has_time(): 1169 hour = max(min(hour, 23), 0) 1170 minute = max(min(minute, 59), 0) 1171 1172 if second is not None: 1173 second = max(min(second, 60), 0) # support leap seconds 1174 1175 self.data[3:6] = hour, minute, second 1176 1177 def __str__(self): 1178 if self.has_time(): 1179 data = self.as_tuple() 1180 time_str = " %02d:%02d" % data[3:5] 1181 if data[5] is not None: 1182 time_str += ":%02d" % data[5] 1183 if data[6] is not None: 1184 time_str += " %s" % data[6] 1185 else: 1186 time_str = "" 1187 1188 return Date.__str__(self) + time_str 1189 1190 def as_datetime(self): 1191 return self 1192 1193 def as_date(self): 1194 return Date(self.data[:3]) 1195 1196 def has_time(self): 1197 return self.data[3] is not None and self.data[4] is not None 1198 1199 def seconds(self): 1200 return self.data[5] 1201 1202 def time_zone(self): 1203 return self.data[6] 1204 1205 def set_time_zone(self, value): 1206 self.data[6] = value 1207 1208 def padded(self): 1209 1210 "Return a datetime with missing fields defined as being zero." 1211 1212 data = map(lambda x: x or 0, self.data[:6]) + self.data[6:] 1213 return DateTime(data) 1214 1215 def to_utc(self): 1216 1217 """ 1218 Return this object converted to UTC, or None if such a conversion is not 1219 defined. 1220 """ 1221 1222 offset = self.utc_offset() 1223 if offset: 1224 hours, minutes = offset 1225 1226 # Invert the offset to get the correction. 1227 1228 hours, minutes = -hours, -minutes 1229 1230 # Get the components. 1231 1232 hour, minute, second, zone = self.as_tuple()[3:] 1233 date = self.as_date() 1234 1235 # Add the minutes and hours. 1236 1237 minute += minutes 1238 if minute < 0 or minute > 59: 1239 hour += minute / 60 1240 minute = minute % 60 1241 1242 # NOTE: This makes various assumptions and probably would not work 1243 # NOTE: for general arithmetic. 1244 1245 hour += hours 1246 if hour < 0: 1247 date = date.previous_day() 1248 hour += 24 1249 elif hour > 23: 1250 date = date.next_day() 1251 hour -= 24 1252 1253 return date.as_datetime(hour, minute, second, "UTC") 1254 1255 # Cannot convert. 1256 1257 else: 1258 return None 1259 1260 def utc_offset(self): 1261 1262 "Return the UTC offset in hours and minutes." 1263 1264 zone = self.time_zone() 1265 if not zone: 1266 return None 1267 1268 # Support explicit UTC zones. 1269 1270 if zone == "UTC": 1271 return 0, 0 1272 1273 # Attempt to return a UTC offset where an explicit offset has been set. 1274 1275 match = timezone_offset_regexp.match(zone) 1276 if match: 1277 if match.group("sign") == "-": 1278 sign = -1 1279 else: 1280 sign = 1 1281 1282 hours = int(match.group("hours")) * sign 1283 minutes = int(match.group("minutes") or 0) * sign 1284 return hours, minutes 1285 1286 # Attempt to handle Olson time zone identifiers. 1287 1288 dt = self.as_olson_datetime() 1289 if dt: 1290 seconds = dt.utcoffset().seconds 1291 hours = seconds / 3600 1292 minutes = (seconds % 3600) / 60 1293 return hours, minutes 1294 1295 # Otherwise return None. 1296 1297 return None 1298 1299 def olson_identifier(self): 1300 1301 "Return the Olson identifier from any zone information." 1302 1303 zone = self.time_zone() 1304 if not zone: 1305 return None 1306 1307 # Attempt to match an identifier. 1308 1309 match = timezone_olson_regexp.match(zone) 1310 if match: 1311 return match.group("olson") 1312 else: 1313 return None 1314 1315 def _as_olson_datetime(self, hours=None): 1316 1317 """ 1318 Return a Python datetime object for this datetime interpreted using any 1319 Olson time zone identifier and the given 'hours' offset, raising one of 1320 the pytz exceptions in case of ambiguity. 1321 """ 1322 1323 olson = self.olson_identifier() 1324 if olson and pytz: 1325 tz = pytz.timezone(olson) 1326 data = self.padded().as_tuple()[:6] 1327 dt = datetime.datetime(*data) 1328 1329 # With an hours offset, find a time probably in a previously 1330 # applicable time zone. 1331 1332 if hours is not None: 1333 td = datetime.timedelta(0, hours * 3600) 1334 dt += td 1335 1336 ldt = tz.localize(dt, None) 1337 1338 # With an hours offset, adjust the time to define it within the 1339 # previously applicable time zone but at the presumably intended 1340 # position. 1341 1342 if hours is not None: 1343 ldt -= td 1344 1345 return ldt 1346 else: 1347 return None 1348 1349 def as_olson_datetime(self): 1350 1351 """ 1352 Return a Python datetime object for this datetime interpreted using any 1353 Olson time zone identifier, choosing the time from the zone before the 1354 period of ambiguity. 1355 """ 1356 1357 try: 1358 return self._as_olson_datetime() 1359 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1360 1361 # Try again, using an earlier local time and then stepping forward 1362 # in the chosen zone. 1363 # NOTE: Four hours earlier seems reasonable. 1364 1365 return self._as_olson_datetime(-4) 1366 1367 def ambiguous(self): 1368 1369 "Return whether the time is local and ambiguous." 1370 1371 try: 1372 self._as_olson_datetime() 1373 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1374 return 1 1375 1376 return 0 1377 1378 class Timespan: 1379 1380 """ 1381 A period of time which can be compared against others to check for overlaps. 1382 """ 1383 1384 def __init__(self, start, end): 1385 self.start = start 1386 self.end = end 1387 1388 def __repr__(self): 1389 return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end) 1390 1391 def __hash__(self): 1392 return hash((self.start, self.end)) 1393 1394 def as_times(self): 1395 return self.start, self.end 1396 1397 def is_before(self, a, b): 1398 if isinstance(a, DateTime) and isinstance(b, DateTime): 1399 return a <= b 1400 else: 1401 return a < b 1402 1403 def is_after_or_during(self, a, b): 1404 if isinstance(a, DateTime) and isinstance(b, DateTime): 1405 return a > b 1406 else: 1407 return a >= b 1408 1409 def __contains__(self, other): 1410 return self == other 1411 1412 def __cmp__(self, other): 1413 1414 """ 1415 Return whether this timespan occupies the same period of time as the 1416 'other'. 1417 """ 1418 1419 if isinstance(other, Timespan): 1420 if self.end is not None and other.start is not None and self.is_before(self.end, other.start): 1421 return -1 1422 elif self.start is not None and other.end is not None and self.is_before(other.end, self.start): 1423 return 1 1424 else: 1425 return 0 1426 1427 # Points in time are not considered to represent an upper bound on a 1428 # non-inclusive timespan. 1429 1430 else: 1431 if self.end is not None and self.is_before(self.end, other): 1432 return -1 1433 elif self.start is not None and self.start > other: 1434 return 1 1435 else: 1436 return 0 1437 1438 class TimespanCollection: 1439 1440 "A collection of timespans with a particular resolution." 1441 1442 def __init__(self, resolution, values=None): 1443 1444 # Timespans need to be given converted start and end dates/times. 1445 1446 if resolution == "date": 1447 self.convert_time = lambda x: x.as_date() 1448 else: 1449 self.convert_time = lambda x: x 1450 1451 self.values = values or [] 1452 1453 def convert(self, value): 1454 if isinstance(value, Event): 1455 value = value.as_timespan() 1456 1457 if isinstance(value, Timespan): 1458 start, end = map(self.convert_time, value.as_times()) 1459 return Timespan(start, end) 1460 else: 1461 return self.convert_time(value) 1462 1463 def __iter__(self): 1464 return iter(self.values) 1465 1466 def __len__(self): 1467 return len(self.values) 1468 1469 def __getitem__(self, i): 1470 return self.values[i] 1471 1472 def __setitem__(self, i, value): 1473 self.values[i] = value 1474 1475 def __contains__(self, value): 1476 test_value = self.convert(value) 1477 return test_value in self.values 1478 1479 def append(self, value): 1480 self.values.append(value) 1481 1482 def insert(self, i, value): 1483 self.values.insert(i, value) 1484 1485 def pop(self): 1486 return self.values.pop() 1487 1488 def insert_in_order(self, value): 1489 bisect.insort_left(self, value) 1490 1491 def getCountry(s): 1492 1493 "Find a country code in the given string 's'." 1494 1495 match = country_code_regexp.search(s) 1496 1497 if match: 1498 return match.group("code") 1499 else: 1500 return None 1501 1502 def getDate(s): 1503 return getDateTime(s).as_date() 1504 1505 def getDateTime(s): 1506 1507 "Parse the string 's', extracting and returning a datetime object." 1508 1509 m = datetime_regexp.search(s) 1510 if m: 1511 groups = list(m.groups()) 1512 1513 # Convert date and time data to integer or None. 1514 1515 return DateTime(map(int_or_none, groups[:6]) + [m.group("zone")]) 1516 else: 1517 return None 1518 1519 def getDateStrings(s): 1520 1521 "Parse the string 's', extracting and returning all date strings." 1522 1523 start = 0 1524 m = date_regexp.search(s, start) 1525 l = [] 1526 while m: 1527 l.append("-".join(m.groups())) 1528 m = date_regexp.search(s, m.end()) 1529 return l 1530 1531 def getMonth(s): 1532 1533 "Parse the string 's', extracting and returning a month object." 1534 1535 m = month_regexp.search(s) 1536 if m: 1537 return Month(map(int, m.groups())) 1538 else: 1539 return None 1540 1541 def getCurrentDate(): 1542 1543 "Return the current date as a (year, month, day) tuple." 1544 1545 today = datetime.date.today() 1546 return Date((today.year, today.month, today.day)) 1547 1548 def getCurrentMonth(): 1549 1550 "Return the current month as a (year, month) tuple." 1551 1552 today = datetime.date.today() 1553 return Month((today.year, today.month)) 1554 1555 def getCurrentYear(): 1556 1557 "Return the current year." 1558 1559 today = datetime.date.today() 1560 return today.year 1561 1562 # User interface functions. 1563 1564 def getParameter(request, name, default=None): 1565 1566 """ 1567 Using the given 'request', return the value of the parameter with the given 1568 'name', returning the optional 'default' (or None) if no value was supplied 1569 in the 'request'. 1570 """ 1571 1572 return get_form(request).get(name, [default])[0] 1573 1574 def getQualifiedParameter(request, calendar_name, argname, default=None): 1575 1576 """ 1577 Using the given 'request', 'calendar_name' and 'argname', retrieve the 1578 value of the qualified parameter, returning the optional 'default' (or None) 1579 if no value was supplied in the 'request'. 1580 """ 1581 1582 argname = getQualifiedParameterName(calendar_name, argname) 1583 return getParameter(request, argname, default) 1584 1585 def getQualifiedParameterName(calendar_name, argname): 1586 1587 """ 1588 Return the qualified parameter name using the given 'calendar_name' and 1589 'argname'. 1590 """ 1591 1592 if calendar_name is None: 1593 return argname 1594 else: 1595 return "%s-%s" % (calendar_name, argname) 1596 1597 def getParameterDate(arg): 1598 1599 "Interpret 'arg', recognising keywords and simple arithmetic operations." 1600 1601 n = None 1602 1603 if arg.startswith("current"): 1604 date = getCurrentDate() 1605 if len(arg) > 8: 1606 n = int(arg[7:]) 1607 1608 elif arg.startswith("yearstart"): 1609 date = Date((getCurrentYear(), 1, 1)) 1610 if len(arg) > 10: 1611 n = int(arg[9:]) 1612 1613 elif arg.startswith("yearend"): 1614 date = Date((getCurrentYear(), 12, 31)) 1615 if len(arg) > 8: 1616 n = int(arg[7:]) 1617 1618 else: 1619 date = getDate(arg) 1620 1621 if n is not None: 1622 date = date.day_update(n) 1623 1624 return date 1625 1626 def getParameterMonth(arg): 1627 1628 "Interpret 'arg', recognising keywords and simple arithmetic operations." 1629 1630 n = None 1631 1632 if arg.startswith("current"): 1633 date = getCurrentMonth() 1634 if len(arg) > 8: 1635 n = int(arg[7:]) 1636 1637 elif arg.startswith("yearstart"): 1638 date = Month((getCurrentYear(), 1)) 1639 if len(arg) > 10: 1640 n = int(arg[9:]) 1641 1642 elif arg.startswith("yearend"): 1643 date = Month((getCurrentYear(), 12)) 1644 if len(arg) > 8: 1645 n = int(arg[7:]) 1646 1647 else: 1648 date = getMonth(arg) 1649 1650 if n is not None: 1651 date = date.month_update(n) 1652 1653 return date 1654 1655 def getFormDate(request, calendar_name, argname): 1656 1657 """ 1658 Return the date from the 'request' for the calendar with the given 1659 'calendar_name' using the parameter having the given 'argname'. 1660 """ 1661 1662 arg = getQualifiedParameter(request, calendar_name, argname) 1663 if arg is not None: 1664 return getParameterDate(arg) 1665 else: 1666 return None 1667 1668 def getFormMonth(request, calendar_name, argname): 1669 1670 """ 1671 Return the month from the 'request' for the calendar with the given 1672 'calendar_name' using the parameter having the given 'argname'. 1673 """ 1674 1675 arg = getQualifiedParameter(request, calendar_name, argname) 1676 if arg is not None: 1677 return getParameterMonth(arg) 1678 else: 1679 return None 1680 1681 def getFormMonthPair(request, yeararg, montharg): 1682 1683 """ 1684 Return the month from the 'request' for the calendar with the given 1685 'calendar_name' using the parameters having the given 'yeararg' and 1686 'montharg' names. 1687 """ 1688 1689 year = getParameter(request, yeararg) 1690 month = getParameter(request, montharg) 1691 if year and month: 1692 return Month((int(year), int(month))) 1693 else: 1694 return None 1695 1696 def getFullDateLabel(request, date): 1697 1698 """ 1699 Return the full month plus year label using the given 'request' and 1700 'year_month'. 1701 """ 1702 1703 _ = request.getText 1704 year, month, day = date.as_tuple()[:3] 1705 start_weekday, number_of_days = date.month_properties() 1706 weekday = (start_weekday + day - 1) % 7 1707 day_label = _(getDayLabel(weekday)) 1708 month_label = _(getMonthLabel(month)) 1709 return "%s %s %s %s" % (day_label, day, month_label, year) 1710 1711 def getFullMonthLabel(request, year_month): 1712 1713 """ 1714 Return the full month plus year label using the given 'request' and 1715 'year_month'. 1716 """ 1717 1718 _ = request.getText 1719 year, month = year_month.as_tuple()[:2] 1720 month_label = _(getMonthLabel(month)) 1721 return "%s %s" % (month_label, year) 1722 1723 # Page-related functions. 1724 1725 def getPrettyPageName(page): 1726 1727 "Return a nicely formatted title/name for the given 'page'." 1728 1729 title = page.split_title(force=1) 1730 return getPrettyTitle(title) 1731 1732 def linkToPage(request, page, text, query_string=None): 1733 1734 """ 1735 Using 'request', return a link to 'page' with the given link 'text' and 1736 optional 'query_string'. 1737 """ 1738 1739 text = wikiutil.escape(text) 1740 return page.link_to_raw(request, text, query_string) 1741 1742 def getFullPageName(parent, title): 1743 1744 """ 1745 Return a full page name from the given 'parent' page (can be empty or None) 1746 and 'title' (a simple page name). 1747 """ 1748 1749 if parent: 1750 return "%s/%s" % (parent.rstrip("/"), title) 1751 else: 1752 return title 1753 1754 def fillEventPageFromTemplate(template_page, new_page, event_details, category_pagenames): 1755 1756 """ 1757 Using the given 'template_page', complete the 'new_page' by copying the 1758 template and adding the given 'event_details' (a dictionary of event 1759 fields), setting also the 'category_pagenames' to define category 1760 membership. 1761 """ 1762 1763 event_page = EventPage(template_page) 1764 new_event_page = EventPage(new_page) 1765 new_event_page.copyPage(event_page) 1766 1767 if new_event_page.getFormat() == "wiki": 1768 new_event = Event(new_event_page, event_details) 1769 new_event_page.setEvents([new_event]) 1770 new_event_page.setCategoryMembership(category_pagenames) 1771 new_event_page.saveChanges() 1772 1773 # vim: tabstop=4 expandtab shiftwidth=4