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