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