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 = getDate(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 __cmp__(self, other): 624 625 """ 626 Compare this object with 'other' using the event start and end details. 627 """ 628 629 details1 = self.details 630 details2 = other.details 631 return cmp( 632 (details1["start"], details1["end"]), 633 (details2["start"], details2["end"]) 634 ) 635 636 def getPage(self): 637 638 "Return the page describing this event." 639 640 return self.page 641 642 def setPage(self, page): 643 644 "Set the 'page' describing this event." 645 646 self.page = page 647 648 def getSummary(self, event_parent=None): 649 650 """ 651 Return either the given title or summary of the event according to the 652 event details, or a summary made from using the pretty version of the 653 page name. 654 655 If the optional 'event_parent' is specified, any page beneath the given 656 'event_parent' page in the page hierarchy will omit this parent information 657 if its name is used as the summary. 658 """ 659 660 event_details = self.details 661 662 if event_details.has_key("title"): 663 return event_details["title"] 664 elif event_details.has_key("summary"): 665 return event_details["summary"] 666 else: 667 # If appropriate, remove the parent details and "/" character. 668 669 title = self.page.getPageName() 670 671 if event_parent and title.startswith(event_parent): 672 title = title[len(event_parent.rstrip("/")) + 1:] 673 674 return getPrettyTitle(title) 675 676 def getDetails(self): 677 678 "Return the details for this event." 679 680 return self.details 681 682 def setDetails(self, event_details): 683 684 "Set the 'event_details' for this event." 685 686 self.details = event_details 687 688 def getEvents(request, category_names, calendar_start=None, calendar_end=None, resolution="month"): 689 690 """ 691 Using the 'request', generate a list of events found on pages belonging to 692 the specified 'category_names', using the optional 'calendar_start' and 693 'calendar_end' values to indicate a window of interest. 694 695 The optional 'resolution' determines the unit of time used in providing the 696 results: 697 698 * a list of events 699 * a dictionary mapping time units to event lists (within the window of 700 interest) 701 * a list of all events within the window of interest 702 * the earliest time value of an event within the window of interest 703 * the latest time value of an event within the window of interest. 704 """ 705 706 # Dates need to comply with the requested resolution. 707 # Here, None values need to be preserved when converting. 708 709 if resolution == "month": 710 convert = lambda x: x and x.as_month() 711 get_values = lambda x, y: x.months_until(y) 712 else: 713 convert = lambda x: x and x.as_date() 714 get_values = lambda x, y: x.days_until(y) 715 716 # Re-order the window, if appropriate. 717 718 if calendar_start is not None and calendar_end is not None and calendar_start > calendar_end: 719 calendar_start, calendar_end = map(convert, (calendar_end, calendar_start)) 720 721 # Otherwise, just convert the calendar limits. 722 723 else: 724 calendar_start, calendar_end = map(convert, (calendar_start, calendar_end)) 725 726 events = [] 727 shown_events = {} 728 all_shown_events = [] 729 processed_pages = set() 730 731 earliest = None 732 latest = None 733 734 for category_name in category_names: 735 736 # Get the pages and page names in the category. 737 738 pages_in_category = getCategoryPages(category_name, request) 739 740 # Visit each page in the category. 741 742 for page_in_category in pages_in_category: 743 pagename = page_in_category.page_name 744 745 # Only process each page once. 746 747 if pagename in processed_pages: 748 continue 749 else: 750 processed_pages.add(pagename) 751 752 # Get a real page, not a result page. 753 754 event_page = EventPage(Page(request, pagename)) 755 756 # Get all events described in the page. 757 758 for event in event_page.getEvents(): 759 event_details = event.getDetails() 760 761 # Remember the event. 762 763 events.append(event) 764 765 # Test for the suitability of the event. 766 767 if event_details.has_key("start") and event_details.has_key("end"): 768 769 start = convert(event_details["start"]) 770 end = convert(event_details["end"]) 771 772 # Compare the dates to the requested calendar window, if any. 773 774 if (calendar_start is None or end >= calendar_start) and \ 775 (calendar_end is None or start <= calendar_end): 776 777 all_shown_events.append(event) 778 779 if earliest is None or start < earliest: 780 earliest = start 781 if latest is None or end > latest: 782 latest = end 783 784 # Store the event in the time-specific dictionary. 785 786 first = max(start, calendar_start or start) 787 last = min(end, calendar_end or end) 788 789 for event_time_value in get_values(first, last): 790 if not shown_events.has_key(event_time_value): 791 shown_events[event_time_value] = [] 792 shown_events[event_time_value].append(event) 793 794 return events, shown_events, all_shown_events, earliest, latest 795 796 def setEventTimestamps(request, events): 797 798 """ 799 Using 'request', set timestamp details in the details dictionary of each of 800 the 'events'. 801 802 Return the latest timestamp found. 803 """ 804 805 latest = None 806 807 for event in events: 808 event_details = event.getDetails() 809 event_page = event.getPage() 810 811 # Get the initial revision of the page. 812 813 revisions = event_page.getRevisions() 814 event_page_initial = Page(request, event_page.getPageName(), rev=revisions[-1]) 815 816 # Get the created and last modified times. 817 818 initial_revision = getPageRevision(event_page_initial) 819 event_details["created"] = initial_revision["timestamp"] 820 latest_revision = event_page.getPageRevision() 821 event_details["last-modified"] = latest_revision["timestamp"] 822 event_details["sequence"] = len(revisions) - 1 823 event_details["last-comment"] = latest_revision["comment"] 824 825 if latest is None or latest < event_details["last-modified"]: 826 latest = event_details["last-modified"] 827 828 return latest 829 830 def getOrderedEvents(events): 831 832 """ 833 Return a list with the given 'events' ordered according to their start and 834 end dates. 835 """ 836 837 ordered_events = events[:] 838 ordered_events.sort() 839 return ordered_events 840 841 def getConcretePeriod(calendar_start, calendar_end, earliest, latest): 842 843 """ 844 From the requested 'calendar_start' and 'calendar_end', which may be None, 845 indicating that no restriction is imposed on the period for each of the 846 boundaries, use the 'earliest' and 'latest' event months to define a 847 specific period of interest. 848 """ 849 850 # Define the period as starting with any specified start month or the 851 # earliest event known, ending with any specified end month or the latest 852 # event known. 853 854 first = calendar_start or earliest 855 last = calendar_end or latest 856 857 # If there is no range of months to show, perhaps because there are no 858 # events in the requested period, and there was no start or end month 859 # specified, show only the month indicated by the start or end of the 860 # requested period. If all events were to be shown but none were found show 861 # the current month. 862 863 if isinstance(first, Date): 864 get_current = getCurrentDate 865 else: 866 get_current = getCurrentMonth 867 868 if first is None: 869 first = last or get_current() 870 if last is None: 871 last = first or get_current() 872 873 # Permit "expiring" periods (where the start date approaches the end date). 874 875 return min(first, last), last 876 877 def getCoverage(start, end, events, resolution="date"): 878 879 """ 880 Within the period defined by the 'start' and 'end' dates, determine the 881 coverage of the days in the period by the given 'events', returning a 882 collection of timespans, along with a dictionary mapping locations to 883 collections of slots, where each slot contains a tuple of the form 884 (timespans, events). 885 """ 886 887 all_events = {} 888 full_coverage = [] 889 890 # Timespans need to be given converted start and end dates/times. 891 892 if resolution == "date": 893 convert = lambda x: x.as_date() 894 else: 895 convert = lambda x: x 896 897 # Get event details. 898 899 for event in events: 900 event_details = event.getDetails() 901 902 # Test for the event in the period. 903 904 if event_details["start"] <= end and event_details["end"] >= start: 905 906 # Find the coverage of this period for the event. 907 908 event_start = convert(max(event_details["start"], start)) 909 event_end = convert(min(event_details["end"], end)) 910 event_coverage = Timespan(event_start, event_end) 911 event_location = event_details.get("location") 912 913 # Update the overall coverage. 914 915 updateCoverage(full_coverage, event_coverage) 916 917 # Add a new events list for a new location. 918 # Locations can be unspecified, thus None refers to all unlocalised 919 # events. 920 921 if not all_events.has_key(event_location): 922 all_events[event_location] = [([event_coverage], [event])] 923 924 # Try and fit the event into an events list. 925 926 else: 927 slot = all_events[event_location] 928 929 for i, (coverage, covered_events) in enumerate(slot): 930 931 # Where the event does not overlap with the current 932 # element, add it alongside existing events. 933 934 if not event_coverage in coverage: 935 covered_events.append(event) 936 updateCoverage(coverage, event_coverage) 937 break 938 939 # Make a new element in the list if the event cannot be 940 # marked alongside existing events. 941 942 else: 943 slot.append(([event_coverage], [event])) 944 945 return full_coverage, all_events 946 947 def updateCoverage(coverage, event_coverage): 948 bisect.insort_left(coverage, event_coverage) 949 950 def getCoverageScale(coverage): 951 times = set() 952 for timespan in coverage: 953 times.add(timespan.start) 954 times.add(timespan.end) 955 times = list(times) 956 times.sort() 957 958 scale = [] 959 first = 1 960 start = None 961 for time in times: 962 if not first: 963 scale.add(Timespan(start, time)) 964 else: 965 first = 0 966 start = time 967 return scale 968 969 # Date-related functions. 970 971 class Period: 972 973 "A simple period of time." 974 975 def __init__(self, data): 976 self.data = data 977 978 def months(self): 979 return self.data[0] * 12 + self.data[1] 980 981 class Temporal: 982 983 "A simple temporal representation, common to dates and times." 984 985 def __init__(self, data): 986 self.data = list(data) 987 988 def __repr__(self): 989 return "%s(%r)" % (self.__class__.__name__, self.data) 990 991 def __hash__(self): 992 return hash(self.as_tuple()) 993 994 def as_tuple(self): 995 return tuple(self.data) 996 997 def __cmp__(self, other): 998 if not isinstance(other, Temporal): 999 return NotImplemented 1000 else: 1001 data = self.as_tuple() 1002 other_data = other.as_tuple() 1003 length = min(len(data), len(other_data)) 1004 return cmp(data[:length], other_data[:length]) 1005 1006 def until(self, start, end, nextfn, prevfn): 1007 1008 """ 1009 Return a collection of units of time by starting from the given 'start' 1010 and stepping across intervening units until 'end' is reached, using the 1011 given 'nextfn' and 'prevfn' to step from one unit to the next. 1012 """ 1013 1014 current = start 1015 units = [current] 1016 if current < end: 1017 while current < end: 1018 current = nextfn(current) 1019 units.append(current) 1020 elif current > end: 1021 while current > end: 1022 current = prevfn(current) 1023 units.append(current) 1024 return units 1025 1026 class Month(Temporal): 1027 1028 "A simple year-month representation." 1029 1030 def __str__(self): 1031 return "%04d-%02d" % self.as_tuple()[:2] 1032 1033 def as_datetime(self, day, hour, minute, second, zone): 1034 return DateTime(self.as_tuple() + (day, hour, minute, second, zone)) 1035 1036 def as_date(self, day): 1037 return Date(self.as_tuple() + (day,)) 1038 1039 def as_month(self): 1040 return self 1041 1042 def year(self): 1043 return self.data[0] 1044 1045 def month(self): 1046 return self.data[1] 1047 1048 def month_properties(self): 1049 1050 """ 1051 Return the weekday of the 1st of the month, along with the number of 1052 days, as a tuple. 1053 """ 1054 1055 year, month = self.as_tuple()[:2] 1056 return calendar.monthrange(year, month) 1057 1058 def month_update(self, n=1): 1059 1060 "Return the month updated by 'n' months." 1061 1062 year, month = self.as_tuple()[:2] 1063 return Month((year + (month - 1 + n) / 12, (month - 1 + n) % 12 + 1)) 1064 1065 def next_month(self): 1066 1067 "Return the month following this one." 1068 1069 return self.month_update(1) 1070 1071 def previous_month(self): 1072 1073 "Return the month preceding this one." 1074 1075 return self.month_update(-1) 1076 1077 def __sub__(self, start): 1078 1079 """ 1080 Return the difference in years and months between this month and the 1081 'start' month as a period. 1082 """ 1083 1084 return Period([(x - y) for x, y in zip(self.data, start.data)]) 1085 1086 def months_until(self, end): 1087 1088 "Return the collection of months from this month until 'end'." 1089 1090 return self.until(self.as_month(), end.as_month(), Month.next_month, Month.previous_month) 1091 1092 class Date(Month): 1093 1094 "A simple year-month-day representation." 1095 1096 def constrain(self): 1097 year, month, day = self.as_tuple()[:3] 1098 1099 month = max(min(month, 12), 1) 1100 wd, last_day = calendar.monthrange(year, month) 1101 day = max(min(day, last_day), 1) 1102 1103 self.data[1:3] = month, day 1104 1105 def __str__(self): 1106 return "%04d-%02d-%02d" % self.as_tuple()[:3] 1107 1108 def as_datetime(self, hour, minute, second, zone): 1109 return DateTime(self.as_tuple() + (hour, minute, second, zone)) 1110 1111 def as_date(self): 1112 return self 1113 1114 def as_month(self): 1115 return Month(self.data[:2]) 1116 1117 def day(self): 1118 return self.data[2] 1119 1120 def day_update(self, n=1): 1121 1122 "Return the month updated by 'n' months." 1123 1124 delta = datetime.timedelta(n) 1125 dt = datetime.date(*self.as_tuple()[:3]) 1126 dt_new = dt + delta 1127 return Date((dt_new.year, dt_new.month, dt_new.day)) 1128 1129 def next_day(self): 1130 1131 "Return the date following this one." 1132 1133 year, month, day = self.as_tuple()[:3] 1134 _wd, end_day = calendar.monthrange(year, month) 1135 if day == end_day: 1136 if month == 12: 1137 return Date((year + 1, 1, 1)) 1138 else: 1139 return Date((year, month + 1, 1)) 1140 else: 1141 return Date((year, month, day + 1)) 1142 1143 def previous_day(self): 1144 1145 "Return the date preceding this one." 1146 1147 year, month, day = self.as_tuple()[:3] 1148 if day == 1: 1149 if month == 1: 1150 return Date((year - 1, 12, 31)) 1151 else: 1152 _wd, end_day = calendar.monthrange(year, month - 1) 1153 return Date((year, month - 1, end_day)) 1154 else: 1155 return Date((year, month, day - 1)) 1156 1157 def days_until(self, end): 1158 1159 "Return the collection of days from this date until 'end'." 1160 1161 return self.until(self.as_date(), end.as_date(), Date.next_day, Date.previous_day) 1162 1163 class DateTime(Date): 1164 1165 "A simple date plus time representation." 1166 1167 def constrain(self): 1168 Date.constrain(self) 1169 1170 hour, minute, second = self.as_tuple()[3:6] 1171 1172 if self.has_time(): 1173 hour = max(min(hour, 23), 0) 1174 minute = max(min(minute, 59), 0) 1175 1176 if second is not None: 1177 second = max(min(second, 60), 0) # support leap seconds 1178 1179 self.data[3:6] = hour, minute, second 1180 1181 def __str__(self): 1182 if self.has_time(): 1183 data = self.as_tuple() 1184 time_str = " %02d:%02d" % data[3:5] 1185 if data[5] is not None: 1186 time_str += ":%02d" % data[5] 1187 if data[6] is not None: 1188 time_str += " %s" % data[6] 1189 else: 1190 time_str = "" 1191 1192 return Date.__str__(self) + time_str 1193 1194 def as_datetime(self): 1195 return self 1196 1197 def as_date(self): 1198 return Date(self.data[:3]) 1199 1200 def has_time(self): 1201 return self.data[3] is not None and self.data[4] is not None 1202 1203 def seconds(self): 1204 return self.data[5] 1205 1206 def time_zone(self): 1207 return self.data[6] 1208 1209 def set_time_zone(self, value): 1210 self.data[6] = value 1211 1212 def padded(self): 1213 1214 "Return a datetime with missing fields defined as being zero." 1215 1216 data = map(lambda x: x or 0, self.data[:6]) + self.data[6:] 1217 return DateTime(data) 1218 1219 def to_utc(self): 1220 1221 """ 1222 Return this object converted to UTC, or None if such a conversion is not 1223 defined. 1224 """ 1225 1226 offset = self.utc_offset() 1227 if offset: 1228 hours, minutes = offset 1229 1230 # Invert the offset to get the correction. 1231 1232 hours, minutes = -hours, -minutes 1233 1234 # Get the components. 1235 1236 hour, minute, second, zone = self.as_tuple()[3:] 1237 date = self.as_date() 1238 1239 # Add the minutes and hours. 1240 1241 minute += minutes 1242 if minute < 0 or minute > 59: 1243 hour += minute / 60 1244 minute = minute % 60 1245 1246 # NOTE: This makes various assumptions and probably would not work 1247 # NOTE: for general arithmetic. 1248 1249 hour += hours 1250 if hour < 0: 1251 date = date.previous_day() 1252 hour += 24 1253 elif hour > 23: 1254 date = date.next_day() 1255 hour -= 24 1256 1257 return date.as_datetime(hour, minute, second, "UTC") 1258 1259 # Cannot convert. 1260 1261 else: 1262 return None 1263 1264 def utc_offset(self): 1265 1266 "Return the UTC offset in hours and minutes." 1267 1268 zone = self.time_zone() 1269 if not zone: 1270 return None 1271 1272 # Support explicit UTC zones. 1273 1274 if zone == "UTC": 1275 return 0, 0 1276 1277 # Attempt to return a UTC offset where an explicit offset has been set. 1278 1279 match = timezone_offset_regexp.match(zone) 1280 if match: 1281 if match.group("sign") == "-": 1282 sign = -1 1283 else: 1284 sign = 1 1285 1286 hours = int(match.group("hours")) * sign 1287 minutes = int(match.group("minutes") or 0) * sign 1288 return hours, minutes 1289 1290 # Attempt to handle Olson time zone identifiers. 1291 1292 dt = self.as_olson_datetime() 1293 if dt: 1294 seconds = dt.utcoffset().seconds 1295 hours = seconds / 3600 1296 minutes = (seconds % 3600) / 60 1297 return hours, minutes 1298 1299 # Otherwise return None. 1300 1301 return None 1302 1303 def olson_identifier(self): 1304 1305 "Return the Olson identifier from any zone information." 1306 1307 zone = self.time_zone() 1308 if not zone: 1309 return None 1310 1311 # Attempt to match an identifier. 1312 1313 match = timezone_olson_regexp.match(zone) 1314 if match: 1315 return match.group("olson") 1316 else: 1317 return None 1318 1319 def _as_olson_datetime(self, hours=None): 1320 1321 """ 1322 Return a Python datetime object for this datetime interpreted using any 1323 Olson time zone identifier and the given 'hours' offset, raising one of 1324 the pytz exceptions in case of ambiguity. 1325 """ 1326 1327 olson = self.olson_identifier() 1328 if olson and pytz: 1329 tz = pytz.timezone(olson) 1330 data = self.padded().as_tuple()[:6] 1331 dt = datetime.datetime(*data) 1332 1333 # With an hours offset, find a time probably in a previously 1334 # applicable time zone. 1335 1336 if hours is not None: 1337 td = datetime.timedelta(0, hours * 3600) 1338 dt += td 1339 1340 ldt = tz.localize(dt, None) 1341 1342 # With an hours offset, adjust the time to define it within the 1343 # previously applicable time zone but at the presumably intended 1344 # position. 1345 1346 if hours is not None: 1347 ldt -= td 1348 1349 return ldt 1350 else: 1351 return None 1352 1353 def as_olson_datetime(self): 1354 1355 """ 1356 Return a Python datetime object for this datetime interpreted using any 1357 Olson time zone identifier, choosing the time from the zone before the 1358 period of ambiguity. 1359 """ 1360 1361 try: 1362 return self._as_olson_datetime() 1363 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1364 1365 # Try again, using an earlier local time and then stepping forward 1366 # in the chosen zone. 1367 # NOTE: Four hours earlier seems reasonable. 1368 1369 return self._as_olson_datetime(-4) 1370 1371 def ambiguous(self): 1372 1373 "Return whether the time is local and ambiguous." 1374 1375 try: 1376 self._as_olson_datetime() 1377 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1378 return 1 1379 1380 return 0 1381 1382 class Timespan: 1383 1384 """ 1385 A period of time which can be compared against others to check for overlaps. 1386 """ 1387 1388 def __init__(self, start, end): 1389 self.start = start 1390 self.end = end 1391 1392 def __repr__(self): 1393 return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end) 1394 1395 def __hash__(self): 1396 return hash((self.start, self.end)) 1397 1398 def is_before(self, a, b): 1399 if isinstance(a, DateTime) and isinstance(b, DateTime): 1400 return a <= b 1401 else: 1402 return a < b 1403 1404 def is_after_or_during(self, a, b): 1405 if isinstance(a, DateTime) and isinstance(b, DateTime): 1406 return a > b 1407 else: 1408 return a >= b 1409 1410 def __contains__(self, other): 1411 if isinstance(other, Timespan): 1412 return self.start <= other.start and self.end >= other.end 1413 else: 1414 return self.start <= other <= self.end 1415 1416 def __cmp__(self, other): 1417 1418 """ 1419 Return whether this timespan occupies the same period of time as the 1420 'other'. 1421 """ 1422 1423 if isinstance(other, Timespan): 1424 if self.is_before(self.end, other.start): 1425 return -1 1426 elif self.is_before(other.end, self.start): 1427 return 1 1428 else: 1429 return 0 1430 1431 # Points in time are not considered to represent an upper bound on a 1432 # non-inclusive timespan. 1433 1434 else: 1435 if self.is_before(self.end, other): 1436 return -1 1437 elif self.start > other: 1438 return 1 1439 else: 1440 return 0 1441 1442 def getCountry(s): 1443 1444 "Find a country code in the given string 's'." 1445 1446 match = country_code_regexp.search(s) 1447 1448 if match: 1449 return match.group("code") 1450 else: 1451 return None 1452 1453 def getDate(s): 1454 1455 "Parse the string 's', extracting and returning a datetime object." 1456 1457 m = datetime_regexp.search(s) 1458 if m: 1459 groups = list(m.groups()) 1460 1461 # Convert date and time data to integer or None. 1462 1463 return DateTime(map(int_or_none, groups[:6]) + [m.group("zone")]) 1464 else: 1465 return None 1466 1467 def getDateStrings(s): 1468 1469 "Parse the string 's', extracting and returning all date strings." 1470 1471 start = 0 1472 m = date_regexp.search(s, start) 1473 l = [] 1474 while m: 1475 l.append("-".join(m.groups())) 1476 m = date_regexp.search(s, m.end()) 1477 return l 1478 1479 def getMonth(s): 1480 1481 "Parse the string 's', extracting and returning a month object." 1482 1483 m = month_regexp.search(s) 1484 if m: 1485 return Month(map(int, m.groups())) 1486 else: 1487 return None 1488 1489 def getCurrentDate(): 1490 1491 "Return the current date as a (year, month, day) tuple." 1492 1493 today = datetime.date.today() 1494 return Date((today.year, today.month, today.day)) 1495 1496 def getCurrentMonth(): 1497 1498 "Return the current month as a (year, month) tuple." 1499 1500 today = datetime.date.today() 1501 return Month((today.year, today.month)) 1502 1503 def getCurrentYear(): 1504 1505 "Return the current year." 1506 1507 today = datetime.date.today() 1508 return today.year 1509 1510 # User interface functions. 1511 1512 def getParameter(request, name, default=None): 1513 1514 """ 1515 Using the given 'request', return the value of the parameter with the given 1516 'name', returning the optional 'default' (or None) if no value was supplied 1517 in the 'request'. 1518 """ 1519 1520 return get_form(request).get(name, [default])[0] 1521 1522 def getQualifiedParameter(request, calendar_name, argname, default=None): 1523 1524 """ 1525 Using the given 'request', 'calendar_name' and 'argname', retrieve the 1526 value of the qualified parameter, returning the optional 'default' (or None) 1527 if no value was supplied in the 'request'. 1528 """ 1529 1530 argname = getQualifiedParameterName(calendar_name, argname) 1531 return getParameter(request, argname, default) 1532 1533 def getQualifiedParameterName(calendar_name, argname): 1534 1535 """ 1536 Return the qualified parameter name using the given 'calendar_name' and 1537 'argname'. 1538 """ 1539 1540 if calendar_name is None: 1541 return argname 1542 else: 1543 return "%s-%s" % (calendar_name, argname) 1544 1545 def getParameterDate(arg): 1546 1547 "Interpret 'arg', recognising keywords and simple arithmetic operations." 1548 1549 n = None 1550 1551 if arg.startswith("current"): 1552 date = getCurrentDate() 1553 if len(arg) > 8: 1554 n = int(arg[7:]) 1555 1556 elif arg.startswith("yearstart"): 1557 date = Date((getCurrentYear(), 1, 1)) 1558 if len(arg) > 10: 1559 n = int(arg[9:]) 1560 1561 elif arg.startswith("yearend"): 1562 date = Date((getCurrentYear(), 12, 31)) 1563 if len(arg) > 8: 1564 n = int(arg[7:]) 1565 1566 else: 1567 date = getDate(arg) 1568 1569 if n is not None: 1570 date = date.day_update(n) 1571 1572 return date 1573 1574 def getParameterMonth(arg): 1575 1576 "Interpret 'arg', recognising keywords and simple arithmetic operations." 1577 1578 n = None 1579 1580 if arg.startswith("current"): 1581 date = getCurrentMonth() 1582 if len(arg) > 8: 1583 n = int(arg[7:]) 1584 1585 elif arg.startswith("yearstart"): 1586 date = Month((getCurrentYear(), 1)) 1587 if len(arg) > 10: 1588 n = int(arg[9:]) 1589 1590 elif arg.startswith("yearend"): 1591 date = Month((getCurrentYear(), 12)) 1592 if len(arg) > 8: 1593 n = int(arg[7:]) 1594 1595 else: 1596 date = getMonth(arg) 1597 1598 if n is not None: 1599 date = date.month_update(n) 1600 1601 return date 1602 1603 def getFormDate(request, calendar_name, argname): 1604 1605 """ 1606 Return the date from the 'request' for the calendar with the given 1607 'calendar_name' using the parameter having the given 'argname'. 1608 """ 1609 1610 arg = getQualifiedParameter(request, calendar_name, argname) 1611 if arg is not None: 1612 return getParameterDate(arg) 1613 else: 1614 return None 1615 1616 def getFormMonth(request, calendar_name, argname): 1617 1618 """ 1619 Return the month from the 'request' for the calendar with the given 1620 'calendar_name' using the parameter having the given 'argname'. 1621 """ 1622 1623 arg = getQualifiedParameter(request, calendar_name, argname) 1624 if arg is not None: 1625 return getParameterMonth(arg) 1626 else: 1627 return None 1628 1629 def getFormMonthPair(request, yeararg, montharg): 1630 1631 """ 1632 Return the month from the 'request' for the calendar with the given 1633 'calendar_name' using the parameters having the given 'yeararg' and 1634 'montharg' names. 1635 """ 1636 1637 year = getParameter(request, yeararg) 1638 month = getParameter(request, montharg) 1639 if year and month: 1640 return Month((int(year), int(month))) 1641 else: 1642 return None 1643 1644 def getFullDateLabel(request, date): 1645 1646 """ 1647 Return the full month plus year label using the given 'request' and 1648 'year_month'. 1649 """ 1650 1651 _ = request.getText 1652 year, month, day = date.as_tuple()[:3] 1653 day_label = _(getDayLabel(day)) 1654 month_label = _(getMonthLabel(month)) 1655 return "%s %s %s %s" % (day_label, day, month_label, year) 1656 1657 def getFullMonthLabel(request, year_month): 1658 1659 """ 1660 Return the full month plus year label using the given 'request' and 1661 'year_month'. 1662 """ 1663 1664 _ = request.getText 1665 year, month = year_month.as_tuple()[:2] 1666 month_label = _(getMonthLabel(month)) 1667 return "%s %s" % (month_label, year) 1668 1669 # Page-related functions. 1670 1671 def getPrettyPageName(page): 1672 1673 "Return a nicely formatted title/name for the given 'page'." 1674 1675 title = page.split_title(force=1) 1676 return getPrettyTitle(title) 1677 1678 def linkToPage(request, page, text, query_string=None): 1679 1680 """ 1681 Using 'request', return a link to 'page' with the given link 'text' and 1682 optional 'query_string'. 1683 """ 1684 1685 text = wikiutil.escape(text) 1686 return page.link_to_raw(request, text, query_string) 1687 1688 def getFullPageName(parent, title): 1689 1690 """ 1691 Return a full page name from the given 'parent' page (can be empty or None) 1692 and 'title' (a simple page name). 1693 """ 1694 1695 if parent: 1696 return "%s/%s" % (parent.rstrip("/"), title) 1697 else: 1698 return title 1699 1700 def fillEventPageFromTemplate(template_page, new_page, event_details, category_pagenames): 1701 1702 """ 1703 Using the given 'template_page', complete the 'new_page' by copying the 1704 template and adding the given 'event_details' (a dictionary of event 1705 fields), setting also the 'category_pagenames' to define category 1706 membership. 1707 """ 1708 1709 event_page = EventPage(template_page) 1710 new_event_page = EventPage(new_page) 1711 new_event_page.copyPage(event_page) 1712 1713 if new_event_page.getFormat() == "wiki": 1714 new_event = Event(new_event_page, event_details) 1715 new_event_page.setEvents([new_event]) 1716 new_event_page.setCategoryMembership(category_pagenames) 1717 new_event_page.saveChanges() 1718 1719 # vim: tabstop=4 expandtab shiftwidth=4