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