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 and [x for x in match.groups() if x] or [] 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 # NOTE: Consider not doing this for month views, since it can lead 895 # NOTE: to poor usage of space. 896 # Locations can be unspecified, thus None refers to all unlocalised 897 # events. 898 899 if not all_events.has_key(event_location): 900 all_events[event_location] = [(event_coverage, [event])] 901 902 # Try and fit the event into an events list. 903 904 else: 905 slot = all_events[event_location] 906 907 for i, (coverage, covered_events) in enumerate(slot): 908 909 # Where the event does not overlap with the current 910 # element, add it alongside existing events. 911 912 if not coverage.intersection(event_coverage): 913 covered_events.append(event) 914 slot[i] = coverage.union(event_coverage), covered_events 915 break 916 917 # Make a new element in the list if the event cannot be 918 # marked alongside existing events. 919 920 else: 921 slot.append((event_coverage, [event])) 922 923 return full_coverage, all_events 924 925 # Date-related functions. 926 927 class Period: 928 929 "A simple period of time." 930 931 def __init__(self, data): 932 self.data = data 933 934 def months(self): 935 return self.data[0] * 12 + self.data[1] 936 937 class Temporal: 938 939 "A simple temporal representation, common to dates and times." 940 941 def __init__(self, data): 942 self.data = list(data) 943 944 def __repr__(self): 945 return "%s(%r)" % (self.__class__.__name__, self.data) 946 947 def __hash__(self): 948 return hash(self.as_tuple()) 949 950 def as_tuple(self): 951 return tuple(self.data) 952 953 def __cmp__(self, other): 954 data = self.as_tuple() 955 other_data = other.as_tuple() 956 length = min(len(data), len(other_data)) 957 return cmp(self.data[:length], other.data[:length]) 958 959 def until(self, start, end, nextfn, prevfn): 960 961 """ 962 Return a collection of units of time by starting from the given 'start' 963 and stepping across intervening units until 'end' is reached, using the 964 given 'nextfn' and 'prevfn' to step from one unit to the next. 965 """ 966 967 current = start 968 units = [current] 969 if current < end: 970 while current < end: 971 current = nextfn(current) 972 units.append(current) 973 elif current > end: 974 while current > end: 975 current = prevfn(current) 976 units.append(current) 977 return units 978 979 class Month(Temporal): 980 981 "A simple year-month representation." 982 983 def __str__(self): 984 return "%04d-%02d" % self.as_tuple()[:2] 985 986 def as_datetime(self, day, hour, minute, second, zone): 987 return DateTime(self.as_tuple() + (day, hour, minute, second, zone)) 988 989 def as_date(self, day): 990 return Date(self.as_tuple() + (day,)) 991 992 def as_month(self): 993 return self 994 995 def year(self): 996 return self.data[0] 997 998 def month(self): 999 return self.data[1] 1000 1001 def month_properties(self): 1002 1003 """ 1004 Return the weekday of the 1st of the month, along with the number of 1005 days, as a tuple. 1006 """ 1007 1008 year, month = self.as_tuple()[:2] 1009 return calendar.monthrange(year, month) 1010 1011 def month_update(self, n=1): 1012 1013 "Return the month updated by 'n' months." 1014 1015 year, month = self.as_tuple()[:2] 1016 return Month((year + (month - 1 + n) / 12, (month - 1 + n) % 12 + 1)) 1017 1018 def next_month(self): 1019 1020 "Return the month following this one." 1021 1022 return self.month_update(1) 1023 1024 def previous_month(self): 1025 1026 "Return the month preceding this one." 1027 1028 return self.month_update(-1) 1029 1030 def __sub__(self, start): 1031 1032 """ 1033 Return the difference in years and months between this month and the 1034 'start' month as a period. 1035 """ 1036 1037 return Period([(x - y) for x, y in zip(self.data, start.data)]) 1038 1039 def months_until(self, end): 1040 1041 "Return the collection of months from this month until 'end'." 1042 1043 return self.until(self.as_month(), end.as_month(), Month.next_month, Month.previous_month) 1044 1045 class Date(Month): 1046 1047 "A simple year-month-day representation." 1048 1049 def constrain(self): 1050 year, month, day = self.as_tuple()[:3] 1051 1052 month = max(min(month, 12), 1) 1053 wd, last_day = calendar.monthrange(year, month) 1054 day = max(min(day, last_day), 1) 1055 1056 self.data[1:3] = month, day 1057 1058 def __str__(self): 1059 return "%04d-%02d-%02d" % self.as_tuple()[:3] 1060 1061 def as_datetime(self, hour, minute, second, zone): 1062 return DateTime(self.as_tuple() + (hour, minute, second, zone)) 1063 1064 def as_date(self): 1065 return self 1066 1067 def as_month(self): 1068 return Month(self.data[:2]) 1069 1070 def day(self): 1071 return self.data[2] 1072 1073 def next_day(self): 1074 1075 "Return the date following this one." 1076 1077 year, month, day = self.as_tuple()[:3] 1078 _wd, end_day = calendar.monthrange(year, month) 1079 if day == end_day: 1080 if month == 12: 1081 return Date((year + 1, 1, 1)) 1082 else: 1083 return Date((year, month + 1, 1)) 1084 else: 1085 return Date((year, month, day + 1)) 1086 1087 def previous_day(self): 1088 1089 "Return the date preceding this one." 1090 1091 year, month, day = self.as_tuple()[:3] 1092 if day == 1: 1093 if month == 1: 1094 return Date((year - 1, 12, 31)) 1095 else: 1096 _wd, end_day = calendar.monthrange(year, month - 1) 1097 return Date((year, month - 1, end_day)) 1098 else: 1099 return Date((year, month, day - 1)) 1100 1101 def days_until(self, end): 1102 1103 "Return the collection of days from this date until 'end'." 1104 1105 return self.until(self.as_date(), end.as_date(), Date.next_day, Date.previous_day) 1106 1107 class DateTime(Date): 1108 1109 "A simple date plus time representation." 1110 1111 def constrain(self): 1112 Date.constrain(self) 1113 1114 hour, minute, second = self.as_tuple()[3:6] 1115 1116 if self.has_time(): 1117 hour = max(min(hour, 23), 0) 1118 minute = max(min(minute, 59), 0) 1119 1120 if second is not None: 1121 second = max(min(second, 60), 0) # support leap seconds 1122 1123 self.data[3:6] = hour, minute, second 1124 1125 def __str__(self): 1126 if self.has_time(): 1127 data = self.as_tuple() 1128 time_str = " %02d:%02d" % data[3:5] 1129 if data[5] is not None: 1130 time_str += ":%02d" % data[5] 1131 if data[6] is not None: 1132 time_str += " %s" % data[6] 1133 else: 1134 time_str = "" 1135 1136 return Date.__str__(self) + time_str 1137 1138 def as_datetime(self): 1139 return self 1140 1141 def as_date(self): 1142 return Date(self.data[:3]) 1143 1144 def has_time(self): 1145 return self.data[3] is not None and self.data[4] is not None 1146 1147 def seconds(self): 1148 return self.data[5] 1149 1150 def time_zone(self): 1151 return self.data[6] 1152 1153 def set_time_zone(self, value): 1154 self.data[6] = value 1155 1156 def padded(self): 1157 1158 "Return a datetime with missing fields defined as being zero." 1159 1160 data = map(lambda x: x or 0, self.data[:6]) + self.data[6:] 1161 return DateTime(data) 1162 1163 def to_utc(self): 1164 1165 """ 1166 Return this object converted to UTC, or None if such a conversion is not 1167 defined. 1168 """ 1169 1170 offset = self.utc_offset() 1171 if offset: 1172 hours, minutes = offset 1173 1174 # Invert the offset to get the correction. 1175 1176 hours, minutes = -hours, -minutes 1177 1178 # Get the components. 1179 1180 hour, minute, second, zone = self.as_tuple()[3:] 1181 date = self.as_date() 1182 1183 # Add the minutes and hours. 1184 1185 minute += minutes 1186 if minute < 0 or minute > 59: 1187 hour += minute / 60 1188 minute = minute % 60 1189 1190 # NOTE: This makes various assumptions and probably would not work 1191 # NOTE: for general arithmetic. 1192 1193 hour += hours 1194 if hour < 0: 1195 date = date.previous_day() 1196 hour += 24 1197 elif hour > 23: 1198 date = date.next_day() 1199 hour -= 24 1200 1201 return date.as_datetime(hour, minute, second, "UTC") 1202 1203 # Cannot convert. 1204 1205 else: 1206 return None 1207 1208 def utc_offset(self): 1209 1210 "Return the UTC offset in hours and minutes." 1211 1212 zone = self.time_zone() 1213 if not zone: 1214 return None 1215 1216 # Support explicit UTC zones. 1217 1218 if zone == "UTC": 1219 return 0, 0 1220 1221 # Attempt to return a UTC offset where an explicit offset has been set. 1222 1223 match = timezone_offset_regexp.match(zone) 1224 if match: 1225 if match.group("sign") == "-": 1226 sign = -1 1227 else: 1228 sign = 1 1229 1230 hours = int(match.group("hours")) * sign 1231 minutes = int(match.group("minutes") or 0) * sign 1232 return hours, minutes 1233 1234 # Attempt to handle Olson time zone identifiers. 1235 1236 dt = self.as_olson_datetime() 1237 if dt: 1238 seconds = dt.utcoffset().seconds 1239 hours = seconds / 3600 1240 minutes = (seconds % 3600) / 60 1241 return hours, minutes 1242 1243 # Otherwise return None. 1244 1245 return None 1246 1247 def olson_identifier(self): 1248 1249 "Return the Olson identifier from any zone information." 1250 1251 zone = self.time_zone() 1252 if not zone: 1253 return None 1254 1255 # Attempt to match an identifier. 1256 1257 match = timezone_olson_regexp.match(zone) 1258 if match: 1259 return match.group("olson") 1260 else: 1261 return None 1262 1263 def _as_olson_datetime(self, hours=None): 1264 1265 """ 1266 Return a Python datetime object for this datetime interpreted using any 1267 Olson time zone identifier and the given 'hours' offset, raising one of 1268 the pytz exceptions in case of ambiguity. 1269 """ 1270 1271 olson = self.olson_identifier() 1272 if olson and pytz: 1273 tz = pytz.timezone(olson) 1274 data = self.padded().as_tuple()[:6] 1275 dt = datetime.datetime(*data) 1276 1277 # With an hours offset, find a time probably in a previously 1278 # applicable time zone. 1279 1280 if hours is not None: 1281 td = datetime.timedelta(0, hours * 3600) 1282 dt += td 1283 1284 ldt = tz.localize(dt, None) 1285 1286 # With an hours offset, adjust the time to define it within the 1287 # previously applicable time zone but at the presumably intended 1288 # position. 1289 1290 if hours is not None: 1291 ldt -= td 1292 1293 return ldt 1294 else: 1295 return None 1296 1297 def as_olson_datetime(self): 1298 1299 """ 1300 Return a Python datetime object for this datetime interpreted using any 1301 Olson time zone identifier, choosing the time from the zone before the 1302 period of ambiguity. 1303 """ 1304 1305 try: 1306 return self._as_olson_datetime() 1307 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1308 1309 # Try again, using an earlier local time and then stepping forward 1310 # in the chosen zone. 1311 # NOTE: Four hours earlier seems reasonable. 1312 1313 return self._as_olson_datetime(-4) 1314 1315 def ambiguous(self): 1316 1317 "Return whether the time is local and ambiguous." 1318 1319 try: 1320 self._as_olson_datetime() 1321 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1322 return 1 1323 1324 return 0 1325 1326 def getCountry(s): 1327 1328 "Find a country code in the given string 's'." 1329 1330 match = country_code_regexp.search(s) 1331 1332 if match: 1333 return match.group("code") 1334 else: 1335 return None 1336 1337 def getDate(s): 1338 1339 "Parse the string 's', extracting and returning a datetime object." 1340 1341 m = datetime_regexp.search(s) 1342 if m: 1343 groups = list(m.groups()) 1344 1345 # Convert date and time data to integer or None. 1346 1347 return DateTime(map(int_or_none, groups[:6]) + [m.group("zone")]) 1348 else: 1349 return None 1350 1351 def getDateStrings(s): 1352 1353 "Parse the string 's', extracting and returning all date strings." 1354 1355 start = 0 1356 m = date_regexp.search(s, start) 1357 l = [] 1358 while m: 1359 l.append("-".join(m.groups())) 1360 m = date_regexp.search(s, m.end()) 1361 return l 1362 1363 def getMonth(s): 1364 1365 "Parse the string 's', extracting and returning a month object." 1366 1367 m = month_regexp.search(s) 1368 if m: 1369 return Month(map(int, m.groups())) 1370 else: 1371 return None 1372 1373 def getCurrentMonth(): 1374 1375 "Return the current month as a (year, month) tuple." 1376 1377 today = datetime.date.today() 1378 return Month((today.year, today.month)) 1379 1380 def getCurrentYear(): 1381 1382 "Return the current year." 1383 1384 today = datetime.date.today() 1385 return today.year 1386 1387 # User interface functions. 1388 1389 def getParameter(request, name, default=None): 1390 1391 """ 1392 Using the given 'request', return the value of the parameter with the given 1393 'name', returning the optional 'default' (or None) if no value was supplied 1394 in the 'request'. 1395 """ 1396 1397 return get_form(request).get(name, [default])[0] 1398 1399 def getQualifiedParameter(request, calendar_name, argname, default=None): 1400 1401 """ 1402 Using the given 'request', 'calendar_name' and 'argname', retrieve the 1403 value of the qualified parameter, returning the optional 'default' (or None) 1404 if no value was supplied in the 'request'. 1405 """ 1406 1407 argname = getQualifiedParameterName(calendar_name, argname) 1408 return getParameter(request, argname, default) 1409 1410 def getQualifiedParameterName(calendar_name, argname): 1411 1412 """ 1413 Return the qualified parameter name using the given 'calendar_name' and 1414 'argname'. 1415 """ 1416 1417 if calendar_name is None: 1418 return argname 1419 else: 1420 return "%s-%s" % (calendar_name, argname) 1421 1422 def getParameterMonth(arg): 1423 1424 "Interpret 'arg', recognising keywords and simple arithmetic operations." 1425 1426 n = None 1427 1428 if arg is None: 1429 return None 1430 1431 elif arg.startswith("current"): 1432 date = getCurrentMonth() 1433 if len(arg) > 8: 1434 n = int(arg[7:]) 1435 1436 elif arg.startswith("yearstart"): 1437 date = Month((getCurrentYear(), 1)) 1438 if len(arg) > 10: 1439 n = int(arg[9:]) 1440 1441 elif arg.startswith("yearend"): 1442 date = Month((getCurrentYear(), 12)) 1443 if len(arg) > 8: 1444 n = int(arg[7:]) 1445 1446 else: 1447 date = getMonth(arg) 1448 1449 if n is not None: 1450 date = date.month_update(n) 1451 1452 return date 1453 1454 def getFormMonth(request, calendar_name, argname): 1455 1456 """ 1457 Return the month from the 'request' for the calendar with the given 1458 'calendar_name' using the parameter having the given 'argname'. 1459 """ 1460 1461 arg = getQualifiedParameter(request, calendar_name, argname) 1462 if arg is not None: 1463 return getParameterMonth(arg) 1464 else: 1465 return None 1466 1467 def getFormMonthPair(request, yeararg, montharg): 1468 1469 """ 1470 Return the month from the 'request' for the calendar with the given 1471 'calendar_name' using the parameters having the given 'yeararg' and 1472 'montharg' names. 1473 """ 1474 1475 year = getParameter(request, yeararg) 1476 month = getParameter(request, montharg) 1477 if year and month: 1478 return Month((int(year), int(month))) 1479 else: 1480 return None 1481 1482 def getFullMonthLabel(request, year_month): 1483 1484 """ 1485 Return the full month plus year label using the given 'request' and 1486 'year_month'. 1487 """ 1488 1489 _ = request.getText 1490 year, month = year_month.as_tuple() 1491 month_label = _(getMonthLabel(month)) 1492 return "%s %s" % (month_label, year) 1493 1494 # Page-related functions. 1495 1496 def getPrettyPageName(page): 1497 1498 "Return a nicely formatted title/name for the given 'page'." 1499 1500 title = page.split_title(force=1) 1501 return getPrettyTitle(title) 1502 1503 def linkToPage(request, page, text, query_string=None): 1504 1505 """ 1506 Using 'request', return a link to 'page' with the given link 'text' and 1507 optional 'query_string'. 1508 """ 1509 1510 text = wikiutil.escape(text) 1511 return page.link_to_raw(request, text, query_string) 1512 1513 def getFullPageName(parent, title): 1514 1515 """ 1516 Return a full page name from the given 'parent' page (can be empty or None) 1517 and 'title' (a simple page name). 1518 """ 1519 1520 if parent: 1521 return "%s/%s" % (parent.rstrip("/"), title) 1522 else: 1523 return title 1524 1525 def fillEventPageFromTemplate(template_page, new_page, event_details, category_pagenames): 1526 1527 """ 1528 Using the given 'template_page', complete the 'new_page' by copying the 1529 template and adding the given 'event_details' (a dictionary of event 1530 fields), setting also the 'category_pagenames' to define category 1531 membership. 1532 """ 1533 1534 event_page = EventPage(template_page) 1535 new_event_page = EventPage(new_page) 1536 new_event_page.copyPage(event_page) 1537 1538 if new_event_page.getFormat() == "wiki": 1539 new_event = Event(new_event_page, event_details) 1540 new_event_page.setEvents([new_event]) 1541 new_event_page.setCategoryMembership(category_pagenames) 1542 new_event_page.saveChanges() 1543 1544 # vim: tabstop=4 expandtab shiftwidth=4