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 codecs 16 import datetime 17 import time 18 import re 19 import bisect 20 import operator 21 import urllib 22 23 try: 24 set 25 except NameError: 26 from sets import Set as set 27 28 try: 29 import pytz 30 except ImportError: 31 pytz = None 32 33 try: 34 import vCalendar 35 except ImportError: 36 vCalendar = None 37 38 escape = wikiutil.escape 39 40 __version__ = "0.7" 41 42 # Date labels. 43 44 month_labels = ["January", "February", "March", "April", "May", "June", 45 "July", "August", "September", "October", "November", "December"] 46 weekday_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 47 48 # Regular expressions where MoinMoin does not provide the required support. 49 50 category_regexp = None 51 52 # Page parsing. 53 54 definition_list_regexp = re.compile(ur'(?P<wholeterm>^(?P<optcomment>#*)\s+(?P<term>.*?):: )(?P<desc>.*?)$', re.UNICODE | re.MULTILINE) 55 category_membership_regexp = re.compile(ur"^\s*(?:(Category\S+)(?:\s+(Category\S+))*)\s*$", re.MULTILINE | re.UNICODE) 56 57 # Value parsing. 58 59 country_code_regexp = re.compile(ur'(?:^|\W)(?P<code>[A-Z]{2})(?:$|\W+$)', re.UNICODE) 60 location_normalised_regexp = re.compile( 61 ur"(?:\d+\w*\s+)?" # preceding postcode (optional) 62 ur"(?P<location>" # start of group of interest 63 ur"\w[\w\s-]+?" # area or town 64 ur"(?:,(?:\s*[\w-]+)+)?" # country (optional) 65 ur")$", re.UNICODE) 66 67 # Month, date, time and datetime parsing. 68 69 month_regexp_str = ur'(?P<year>[0-9]{4})-(?P<month>[0-9]{2})' 70 date_regexp_str = ur'(?P<year>[0-9]{4})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})' 71 time_regexp_str = ur'(?P<hour>[0-2][0-9]):(?P<minute>[0-5][0-9])(?::(?P<second>[0-6][0-9]))?' 72 timezone_offset_str = ur'(?P<offset>(UTC)?(?:(?P<sign>[-+])(?P<hours>[0-9]{2})(?::?(?P<minutes>[0-9]{2}))?))' 73 timezone_olson_str = ur'(?P<olson>[a-zA-Z]+(?:/[-_a-zA-Z]+){1,2})' 74 timezone_utc_str = ur'UTC' 75 timezone_regexp_str = ur'(?P<zone>' + timezone_offset_str + '|' + timezone_olson_str + '|' + timezone_utc_str + ')' 76 datetime_regexp_str = date_regexp_str + ur'(?:\s+' + time_regexp_str + ur'(?:\s+' + timezone_regexp_str + ur')?)?' 77 78 month_regexp = re.compile(month_regexp_str, re.UNICODE) 79 date_regexp = re.compile(date_regexp_str, re.UNICODE) 80 time_regexp = re.compile(time_regexp_str, re.UNICODE) 81 timezone_olson_regexp = re.compile(timezone_olson_str, re.UNICODE) 82 timezone_offset_regexp = re.compile(timezone_offset_str, re.UNICODE) 83 datetime_regexp = re.compile(datetime_regexp_str, re.UNICODE) 84 85 # iCalendar date and datetime parsing. 86 87 date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})' 88 datetime_icalendar_regexp_str = date_icalendar_regexp_str + \ 89 ur'(?:' \ 90 ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \ 91 ur'(?P<utc>Z)?' \ 92 ur')?' 93 94 date_icalendar_regexp = re.compile(date_icalendar_regexp_str, re.UNICODE) 95 datetime_icalendar_regexp = re.compile(datetime_icalendar_regexp_str, re.UNICODE) 96 97 # Simple content parsing. 98 99 verbatim_regexp = re.compile(ur'(?:' 100 ur'<<Verbatim\((?P<verbatim>.*?)\)>>' 101 ur'|' 102 ur'\[\[Verbatim\((?P<verbatim2>.*?)\)\]\]' 103 ur'|' 104 ur'!(?P<verbatim3>.*?)(\s|$)?' 105 ur'|' 106 ur'`(?P<monospace>.*?)`' 107 ur'|' 108 ur'{{{(?P<preformatted>.*?)}}}' 109 ur')', re.UNICODE) 110 111 # Utility functions. 112 113 def getCategoryPattern(request): 114 global category_regexp 115 116 try: 117 return request.cfg.cache.page_category_regexact 118 except AttributeError: 119 120 # Use regular expression from MoinMoin 1.7.1 otherwise. 121 122 if category_regexp is None: 123 category_regexp = re.compile(u'^%s$' % ur'(?P<all>Category(?P<key>(?!Template)\S+))', re.UNICODE) 124 return category_regexp 125 126 def int_or_none(x): 127 if x is None: 128 return x 129 else: 130 return int(x) 131 132 def sort_none_first(x, y): 133 if x is None: 134 return -1 135 elif y is None: 136 return 1 137 else: 138 return cmp(x, y) 139 140 def sort_start_first(x, y): 141 x_ts = x.as_limits() 142 if x_ts is not None: 143 x_start, x_end = x_ts 144 y_ts = y.as_limits() 145 if y_ts is not None: 146 y_start, y_end = y_ts 147 start_order = cmp(x_start, y_start) 148 if start_order == 0: 149 return cmp(x_end, y_end) 150 else: 151 return start_order 152 return 0 153 154 def sign(x): 155 if x < 0: 156 return -1 157 else: 158 return 1 159 160 # Utility classes and associated functions. 161 162 class Form: 163 164 """ 165 A wrapper preserving MoinMoin 1.8.x (and earlier) behaviour in a 1.9.x 166 environment. 167 """ 168 169 def __init__(self, form): 170 self.form = form 171 172 def get(self, name, default=None): 173 values = self.form.getlist(name) 174 if not values: 175 return default 176 else: 177 return values 178 179 def __getitem__(self, name): 180 return self.form.getlist(name) 181 182 class ActionSupport: 183 184 """ 185 Work around disruptive MoinMoin changes in 1.9, and also provide useful 186 convenience methods. 187 """ 188 189 def get_form(self): 190 return get_form(self.request) 191 192 def _get_selected(self, value, input_value): 193 194 """ 195 Return the HTML attribute text indicating selection of an option (or 196 otherwise) if 'value' matches 'input_value'. 197 """ 198 199 return input_value is not None and value == input_value and 'selected="selected"' or '' 200 201 def _get_selected_for_list(self, value, input_values): 202 203 """ 204 Return the HTML attribute text indicating selection of an option (or 205 otherwise) if 'value' matches one of the 'input_values'. 206 """ 207 208 return value in input_values and 'selected="selected"' or '' 209 210 def _get_input(self, form, name, default=None): 211 212 """ 213 Return the input from 'form' having the given 'name', returning either 214 the input converted to an integer or the given 'default' (optional, None 215 if not specified). 216 """ 217 218 value = form.get(name, [None])[0] 219 if not value: # true if 0 obtained 220 return default 221 else: 222 return int(value) 223 224 def get_month_lists(self, default_as_current=0): 225 226 """ 227 Return two lists of HTML element definitions corresponding to the start 228 and end month selection controls, with months selected according to any 229 values that have been specified via request parameters. 230 """ 231 232 _ = self._ 233 form = self.get_form() 234 235 # Initialise month lists. 236 237 start_month_list = [] 238 end_month_list = [] 239 240 start_month = self._get_input(form, "start-month", default_as_current and getCurrentMonth().month() or None) 241 end_month = self._get_input(form, "end-month", start_month) 242 243 # Prepare month lists, selecting specified months. 244 245 if not default_as_current: 246 start_month_list.append('<option value=""></option>') 247 end_month_list.append('<option value=""></option>') 248 249 for month in range(1, 13): 250 month_label = escape(_(getMonthLabel(month))) 251 selected = self._get_selected(month, start_month) 252 start_month_list.append('<option value="%02d" %s>%s</option>' % (month, selected, month_label)) 253 selected = self._get_selected(month, end_month) 254 end_month_list.append('<option value="%02d" %s>%s</option>' % (month, selected, month_label)) 255 256 return start_month_list, end_month_list 257 258 def get_year_defaults(self, default_as_current=0): 259 260 "Return defaults for the start and end years." 261 262 form = self.get_form() 263 264 start_year_default = form.get("start-year", [default_as_current and getCurrentYear() or ""])[0] 265 end_year_default = form.get("end-year", [default_as_current and start_year_default or ""])[0] 266 267 return start_year_default, end_year_default 268 269 def get_day_defaults(self, default_as_current=0): 270 271 "Return defaults for the start and end days." 272 273 form = self.get_form() 274 275 start_day_default = form.get("start-day", [default_as_current and getCurrentDate().day() or ""])[0] 276 end_day_default = form.get("end-day", [default_as_current and start_day_default or ""])[0] 277 278 return start_day_default, end_day_default 279 280 def get_form(request): 281 282 "Work around disruptive MoinMoin changes in 1.9." 283 284 if hasattr(request, "values"): 285 return Form(request.values) 286 else: 287 return request.form 288 289 class send_headers_cls: 290 291 """ 292 A wrapper to preserve MoinMoin 1.8.x (and earlier) request behaviour in a 293 1.9.x environment. 294 """ 295 296 def __init__(self, request): 297 self.request = request 298 299 def __call__(self, headers): 300 for header in headers: 301 parts = header.split(":") 302 self.request.headers.add(parts[0], ":".join(parts[1:])) 303 304 def escattr(s): 305 return escape(s, 1) 306 307 # Textual representations. 308 309 def getHTTPTimeString(tmtuple): 310 return "%s, %02d %s %04d %02d:%02d:%02d GMT" % ( 311 getDayLabel(tmtuple.tm_wday), 312 tmtuple.tm_mday, 313 getMonthLabel(tmtuple.tm_mon), 314 tmtuple.tm_year, 315 tmtuple.tm_hour, 316 tmtuple.tm_min, 317 tmtuple.tm_sec 318 ) 319 320 def getSimpleWikiText(text): 321 322 """ 323 Return the plain text representation of the given 'text' which may employ 324 certain Wiki syntax features, such as those providing verbatim or monospaced 325 text. 326 """ 327 328 # NOTE: Re-implementing support for verbatim text and linking avoidance. 329 330 return "".join([s for s in verbatim_regexp.split(text) if s is not None]) 331 332 def getEncodedWikiText(text): 333 334 "Encode the given 'text' in a verbatim representation." 335 336 return "<<Verbatim(%s)>>" % text 337 338 def getPrettyTitle(title): 339 340 "Return a nicely formatted version of the given 'title'." 341 342 return title.replace("_", " ").replace("/", u" ? ") 343 344 def getMonthLabel(month): 345 346 "Return an unlocalised label for the given 'month'." 347 348 return month_labels[month - 1] # zero-based labels 349 350 def getDayLabel(weekday): 351 352 "Return an unlocalised label for the given 'weekday'." 353 354 return weekday_labels[weekday] 355 356 def getNormalisedLocation(location): 357 358 """ 359 Attempt to return a normalised 'location' of the form "<town>, <country>" or 360 "<town>". 361 """ 362 363 match = location_normalised_regexp.search(location) 364 if match: 365 return match.group("location") 366 else: 367 return None 368 369 def getLocationPosition(location, locations): 370 371 """ 372 Attempt to return the position of the given 'location' using the 'locations' 373 dictionary provided. If no position can be found, return a latitude of None 374 and a longitude of None. 375 """ 376 377 latitude, longitude = None, None 378 379 if location is not None: 380 try: 381 latitude, longitude = map(getMapReference, locations[location].split()) 382 except (KeyError, ValueError): 383 pass 384 385 return latitude, longitude 386 387 # Action support functions. 388 389 def getPageRevision(page): 390 391 "Return the revision details dictionary for the given 'page'." 392 393 # From Page.edit_info... 394 395 if hasattr(page, "editlog_entry"): 396 line = page.editlog_entry() 397 else: 398 line = page._last_edited(page.request) # MoinMoin 1.5.x and 1.6.x 399 400 # Similar to Page.mtime_usecs behaviour... 401 402 if line: 403 timestamp = line.ed_time_usecs 404 mtime = wikiutil.version2timestamp(long(timestamp)) # must be long for py 2.2.x 405 comment = line.comment 406 else: 407 mtime = 0 408 comment = "" 409 410 return {"timestamp" : time.gmtime(mtime), "comment" : comment} 411 412 # Category discovery and searching. 413 414 def getCategories(request): 415 416 """ 417 From the AdvancedSearch macro, return a list of category page names using 418 the given 'request'. 419 """ 420 421 # This will return all pages with "Category" in the title. 422 423 cat_filter = getCategoryPattern(request).search 424 return request.rootpage.getPageList(filter=cat_filter) 425 426 def getCategoryMapping(category_pagenames, request): 427 428 """ 429 For the given 'category_pagenames' return a list of tuples of the form 430 (category name, category page name) using the given 'request'. 431 """ 432 433 cat_pattern = getCategoryPattern(request) 434 mapping = [] 435 for pagename in category_pagenames: 436 name = cat_pattern.match(pagename).group("key") 437 if name != "Category": 438 mapping.append((name, pagename)) 439 mapping.sort() 440 return mapping 441 442 def getCategoryPages(pagename, request): 443 444 """ 445 Return the pages associated with the given category 'pagename' using the 446 'request'. 447 """ 448 449 query = search.QueryParser().parse_query('category:%s' % pagename) 450 results = search.searchPages(request, query, "page_name") 451 452 cat_pattern = getCategoryPattern(request) 453 pages = [] 454 for page in results.hits: 455 if not cat_pattern.match(page.page_name): 456 pages.append(page) 457 return pages 458 459 def getAllCategoryPages(category_names, request): 460 461 """ 462 Return all pages belonging to the categories having the given 463 'category_names', using the given 'request'. 464 """ 465 466 pages = [] 467 pagenames = set() 468 469 for category_name in category_names: 470 471 # Get the pages and page names in the category. 472 473 pages_in_category = getCategoryPages(category_name, request) 474 475 # Visit each page in the category. 476 477 for page_in_category in pages_in_category: 478 pagename = page_in_category.page_name 479 480 # Only process each page once. 481 482 if pagename in pagenames: 483 continue 484 else: 485 pagenames.add(pagename) 486 487 pages.append(page_in_category) 488 489 return pages 490 491 def getPagesFromResults(result_pages, request): 492 493 "Return genuine pages for the given 'result_pages' using the 'request'." 494 495 return [Page(request, page.page_name) for page in result_pages] 496 497 # Interfaces. 498 499 class ActsAsTimespan: 500 pass 501 502 # Event resources providing collections of events. 503 504 class EventResource: 505 506 "A resource providing event information." 507 508 def __init__(self, url): 509 self.url = url 510 511 def getPageURL(self, request): 512 513 "Using 'request', return the URL of this page." 514 515 return self.url 516 517 def getFormat(self): 518 519 "Get the format used on this page." 520 521 return "plain" 522 523 def getEvents(self): 524 525 "Return a list of events from this resource." 526 527 return [] 528 529 def linkToPage(self, request, text, query_string=None): 530 531 """ 532 Using 'request', return a link to this page with the given link 'text' 533 and optional 'query_string'. 534 """ 535 536 url = self.url 537 538 if query_string: 539 query_string = wikiutil.makeQueryString(query_string) 540 url = "%s?%s" % (url, query_string) 541 542 formatter = request.page and getattr(request.page, "formatter", None) or request.html_formatter 543 544 output = [] 545 output.append(formatter.url(1, url)) 546 output.append(formatter.text(text)) 547 output.append(formatter.url(0)) 548 return "".join(output) 549 550 # Formatting-related functions. 551 552 def formatText(self, text, request, fmt): 553 554 """ 555 Format the given 'text' using the specified 'request' and formatter 556 'fmt'. 557 """ 558 559 # Assume plain text which is then formatted appropriately. 560 561 return fmt.text(text) 562 563 class EventCalendar(EventResource): 564 565 "An iCalendar resource." 566 567 def __init__(self, url, calendar): 568 EventResource.__init__(self, url) 569 self.calendar = calendar 570 self.events = None 571 572 def getEvents(self): 573 574 "Return a list of events from this resource." 575 576 if self.events is None: 577 self.events = [] 578 579 _calendar, _empty, calendar = self.calendar 580 581 for objtype, attrs, obj in calendar: 582 583 # Read events. 584 585 if objtype == "VEVENT": 586 details = {} 587 588 for property, attrs, value in obj: 589 590 # Convert dates. 591 592 if property in ("DTSTART", "DTEND"): 593 property = property[2:].lower() 594 if attrs.get("VALUE") == "DATE": 595 value = getDateFromCalendar(value) 596 else: 597 value = getDateTimeFromCalendar(value) 598 599 # Accept other textual data as it is. 600 601 elif property in ("CATEGORIES", "LOCATION", "SUMMARY"): 602 property = property.lower() 603 604 else: 605 continue 606 607 details[property] = value 608 609 self.events.append(Event(self, details)) 610 611 return self.events 612 613 class EventPage: 614 615 "An event page." 616 617 def __init__(self, page): 618 self.page = page 619 self.events = None 620 self.body = None 621 self.categories = None 622 623 def copyPage(self, page): 624 625 "Copy the body of the given 'page'." 626 627 self.body = page.getBody() 628 629 def getPageURL(self, request): 630 631 "Using 'request', return the URL of this page." 632 633 return request.getQualifiedURL(self.page.url(request, relative=0)) 634 635 def getFormat(self): 636 637 "Get the format used on this page." 638 639 return self.page.pi["format"] 640 641 def getRevisions(self): 642 643 "Return a list of page revisions." 644 645 return self.page.getRevList() 646 647 def getPageRevision(self): 648 649 "Return the revision details dictionary for this page." 650 651 return getPageRevision(self.page) 652 653 def getPageName(self): 654 655 "Return the page name." 656 657 return self.page.page_name 658 659 def getPrettyPageName(self): 660 661 "Return a nicely formatted title/name for this page." 662 663 return getPrettyPageName(self.page) 664 665 def getBody(self): 666 667 "Get the current page body." 668 669 if self.body is None: 670 self.body = self.page.get_raw_body() 671 return self.body 672 673 def getEvents(self): 674 675 "Return a list of events from this page." 676 677 if self.events is None: 678 details = {} 679 self.events = [Event(self, details)] 680 681 if self.getFormat() == "wiki": 682 for match in definition_list_regexp.finditer(self.getBody()): 683 684 # Skip commented-out items. 685 686 if match.group("optcomment"): 687 continue 688 689 # Permit case-insensitive list terms. 690 691 term = match.group("term").lower() 692 desc = match.group("desc") 693 694 # Special value type handling. 695 696 # Dates. 697 698 if term in ("start", "end"): 699 desc = getDateTime(desc) 700 701 # Lists (whose elements may be quoted). 702 703 elif term in ("topics", "categories"): 704 desc = [getSimpleWikiText(value.strip()) for value in desc.split(",") if value.strip()] 705 706 # Labels which may well be quoted. 707 708 elif term in ("title", "summary", "description", "location"): 709 desc = getSimpleWikiText(desc.strip()) 710 711 if desc is not None: 712 713 # Handle apparent duplicates by creating a new set of 714 # details. 715 716 if details.has_key(term): 717 718 # Make a new event. 719 720 details = {} 721 self.events.append(Event(self, details)) 722 723 details[term] = desc 724 725 return self.events 726 727 def setEvents(self, events): 728 729 "Set the given 'events' on this page." 730 731 self.events = events 732 733 def getCategoryMembership(self): 734 735 "Get the category names from this page." 736 737 if self.categories is None: 738 body = self.getBody() 739 match = category_membership_regexp.search(body) 740 self.categories = match and [x for x in match.groups() if x] or [] 741 742 return self.categories 743 744 def setCategoryMembership(self, category_names): 745 746 """ 747 Set the category membership for the page using the specified 748 'category_names'. 749 """ 750 751 self.categories = category_names 752 753 def flushEventDetails(self): 754 755 "Flush the current event details to this page's body text." 756 757 new_body_parts = [] 758 end_of_last_match = 0 759 body = self.getBody() 760 761 events = iter(self.getEvents()) 762 763 event = events.next() 764 event_details = event.getDetails() 765 replaced_terms = set() 766 767 for match in definition_list_regexp.finditer(body): 768 769 # Permit case-insensitive list terms. 770 771 term = match.group("term").lower() 772 desc = match.group("desc") 773 774 # Check that the term has not already been substituted. If so, 775 # get the next event. 776 777 if term in replaced_terms: 778 try: 779 event = events.next() 780 781 # No more events. 782 783 except StopIteration: 784 break 785 786 event_details = event.getDetails() 787 replaced_terms = set() 788 789 # Add preceding text to the new body. 790 791 new_body_parts.append(body[end_of_last_match:match.start()]) 792 793 # Get the matching regions, adding the term to the new body. 794 795 new_body_parts.append(match.group("wholeterm")) 796 797 # Special value type handling. 798 799 if event_details.has_key(term): 800 801 # Dates. 802 803 if term in ("start", "end"): 804 desc = desc.replace("YYYY-MM-DD", str(event_details[term])) 805 806 # Lists (whose elements may be quoted). 807 808 elif term in ("topics", "categories"): 809 desc = ", ".join([getEncodedWikiText(item) for item in event_details[term]]) 810 811 # Labels which must be quoted. 812 813 elif term in ("title", "summary"): 814 desc = getEncodedWikiText(event_details[term]) 815 816 # Text which need not be quoted, but it will be Wiki text. 817 818 elif term in ("description", "link", "location"): 819 desc = event_details[term] 820 821 replaced_terms.add(term) 822 823 # Add the replaced value. 824 825 new_body_parts.append(desc) 826 827 # Remember where in the page has been processed. 828 829 end_of_last_match = match.end() 830 831 # Write the rest of the page. 832 833 new_body_parts.append(body[end_of_last_match:]) 834 835 self.body = "".join(new_body_parts) 836 837 def flushCategoryMembership(self): 838 839 "Flush the category membership to the page body." 840 841 body = self.getBody() 842 category_names = self.getCategoryMembership() 843 match = category_membership_regexp.search(body) 844 845 if match: 846 self.body = "".join([body[:match.start()], " ".join(category_names), body[match.end():]]) 847 848 def saveChanges(self): 849 850 "Save changes to the event." 851 852 self.flushEventDetails() 853 self.flushCategoryMembership() 854 self.page.saveText(self.getBody(), 0) 855 856 def linkToPage(self, request, text, query_string=None): 857 858 """ 859 Using 'request', return a link to this page with the given link 'text' 860 and optional 'query_string'. 861 """ 862 863 return linkToPage(request, self.page, text, query_string) 864 865 # Formatting-related functions. 866 867 def getParserClass(self, request, format): 868 869 """ 870 Return a parser class using the 'request' for the given 'format', returning 871 a plain text parser if no parser can be found for the specified 'format'. 872 """ 873 874 try: 875 return wikiutil.searchAndImportPlugin(request.cfg, "parser", format or "plain") 876 except wikiutil.PluginMissingError: 877 return wikiutil.searchAndImportPlugin(request.cfg, "parser", "plain") 878 879 def formatText(self, text, request, fmt): 880 881 """ 882 Format the given 'text' using the specified 'request' and formatter 883 'fmt'. 884 """ 885 886 # Suppress line anchors. 887 888 parser_cls = self.getParserClass(request, self.getFormat()) 889 parser = parser_cls(text, request, line_anchors=False) 890 891 # Fix lists by indicating that a paragraph is already started. 892 893 return request.redirectedOutput(parser.format, fmt, inhibit_p=True) 894 895 # Event details. 896 897 class Event(ActsAsTimespan): 898 899 "A description of an event." 900 901 def __init__(self, page, details): 902 self.page = page 903 self.details = details 904 905 # Permit omission of the end of the event by duplicating the start. 906 907 if self.details.has_key("start") and not self.details.has_key("end"): 908 self.details["end"] = self.details["start"] 909 910 def __repr__(self): 911 return "<Event %r %r>" % (self.getSummary(), self.as_limits()) 912 913 def __hash__(self): 914 return hash(self.getSummary()) 915 916 def getPage(self): 917 918 "Return the page describing this event." 919 920 return self.page 921 922 def setPage(self, page): 923 924 "Set the 'page' describing this event." 925 926 self.page = page 927 928 def getSummary(self, event_parent=None): 929 930 """ 931 Return either the given title or summary of the event according to the 932 event details, or a summary made from using the pretty version of the 933 page name. 934 935 If the optional 'event_parent' is specified, any page beneath the given 936 'event_parent' page in the page hierarchy will omit this parent information 937 if its name is used as the summary. 938 """ 939 940 event_details = self.details 941 942 if event_details.has_key("title"): 943 return event_details["title"] 944 elif event_details.has_key("summary"): 945 return event_details["summary"] 946 else: 947 # If appropriate, remove the parent details and "/" character. 948 949 title = self.page.getPageName() 950 951 if event_parent and title.startswith(event_parent): 952 title = title[len(event_parent.rstrip("/")) + 1:] 953 954 return getPrettyTitle(title) 955 956 def getDetails(self): 957 958 "Return the details for this event." 959 960 return self.details 961 962 def setDetails(self, event_details): 963 964 "Set the 'event_details' for this event." 965 966 self.details = event_details 967 968 # Timespan-related methods. 969 970 def __contains__(self, other): 971 return self == other 972 973 def __cmp__(self, other): 974 if isinstance(other, Event): 975 return cmp(self.as_timespan(), other.as_timespan()) 976 else: 977 return cmp(self.as_timespan(), other) 978 979 def as_timespan(self): 980 details = self.details 981 if details.has_key("start") and details.has_key("end"): 982 return Timespan(details["start"], details["end"]) 983 else: 984 return None 985 986 def as_limits(self): 987 ts = self.as_timespan() 988 return ts and ts.as_limits() 989 990 # Obtaining event containers and events from such containers. 991 992 def getEventPages(pages): 993 994 "Return a list of events found on the given 'pages'." 995 996 # Get real pages instead of result pages. 997 998 return map(EventPage, pages) 999 1000 def getEventResources(sources, calendar_start, calendar_end, request): 1001 1002 """ 1003 Return resource objects for the given 'sources' using the given 1004 'calendar_start' and 'calendar_end' to parameterise requests to the sources, 1005 and the 'request' to access configuration settings in the Wiki. 1006 """ 1007 1008 sources_page = getattr(request.cfg, "event_aggregator_sources_page", "EventSourcesDict") 1009 1010 # Remote sources are accessed via dictionary page definitions. 1011 1012 if request.user.may.read(sources_page): 1013 sources_dict = request.dicts.dict(sources_page) 1014 else: 1015 return [] 1016 1017 # Use dates for the calendar limits. 1018 1019 if isinstance(calendar_start, Month): 1020 calendar_start = calendar_start.as_date(1) 1021 1022 if isinstance(calendar_end, Month): 1023 calendar_end = calendar_end.as_date(-1) 1024 1025 resources = [] 1026 1027 for source in sources: 1028 try: 1029 url, format = sources_dict[source].split() 1030 1031 # Prevent local file access. 1032 1033 if url.startswith("file:"): 1034 continue 1035 1036 # Parameterise the URL. 1037 1038 url = url.replace("{start}", calendar_start and str(calendar_start) or "") 1039 url = url.replace("{end}", calendar_end and str(calendar_end) or "") 1040 1041 # Get a parser. 1042 1043 if format == "ical" and vCalendar is not None: 1044 parser = vCalendar.parse 1045 resource_cls = EventCalendar 1046 else: 1047 continue 1048 1049 # Access the remote data source. 1050 1051 f = urllib.urlopen(url) 1052 1053 # NOTE: Should look at the metadata first. 1054 1055 uf = codecs.getreader("utf-8")(f) 1056 1057 try: 1058 resources.append(resource_cls(url, parser(uf))) 1059 finally: 1060 f.close() 1061 uf.close() 1062 1063 except (KeyError, ValueError): 1064 pass 1065 1066 return resources 1067 1068 def getEventsFromResources(resources): 1069 1070 "Return a list of events supplied by the given event 'resources'." 1071 1072 events = [] 1073 1074 for resource in resources: 1075 1076 # Get all events described by the resource. 1077 1078 for event in resource.getEvents(): 1079 1080 # Remember the event. 1081 1082 events.append(event) 1083 1084 return events 1085 1086 # Event filtering and limits. 1087 1088 def getEventsInPeriod(events, calendar_period): 1089 1090 """ 1091 Return a collection containing those of the given 'events' which occur 1092 within the given 'calendar_period'. 1093 """ 1094 1095 all_shown_events = [] 1096 1097 for event in events: 1098 1099 # Test for the suitability of the event. 1100 1101 if event.as_timespan() is not None: 1102 1103 # Compare the dates to the requested calendar window, if any. 1104 1105 if event in calendar_period: 1106 all_shown_events.append(event) 1107 1108 return all_shown_events 1109 1110 def getEventLimits(events): 1111 1112 "Return the earliest and latest of the given 'events'." 1113 1114 earliest = None 1115 latest = None 1116 1117 for event in events: 1118 1119 # Test for the suitability of the event. 1120 1121 if event.as_timespan() is not None: 1122 ts = event.as_timespan() 1123 if earliest is None or ts.start < earliest: 1124 earliest = ts.start 1125 if latest is None or ts.end > latest: 1126 latest = ts.end 1127 1128 return earliest, latest 1129 1130 def setEventTimestamps(request, events): 1131 1132 """ 1133 Using 'request', set timestamp details in the details dictionary of each of 1134 the 'events'. 1135 1136 Return the latest timestamp found. 1137 """ 1138 1139 latest = None 1140 1141 for event in events: 1142 event_details = event.getDetails() 1143 event_page = event.getPage() 1144 1145 # Get the initial revision of the page. 1146 1147 revisions = event_page.getRevisions() 1148 event_page_initial = Page(request, event_page.getPageName(), rev=revisions[-1]) 1149 1150 # Get the created and last modified times. 1151 1152 initial_revision = getPageRevision(event_page_initial) 1153 event_details["created"] = initial_revision["timestamp"] 1154 latest_revision = event_page.getPageRevision() 1155 event_details["last-modified"] = latest_revision["timestamp"] 1156 event_details["sequence"] = len(revisions) - 1 1157 event_details["last-comment"] = latest_revision["comment"] 1158 1159 if latest is None or latest < event_details["last-modified"]: 1160 latest = event_details["last-modified"] 1161 1162 return latest 1163 1164 def getOrderedEvents(events): 1165 1166 """ 1167 Return a list with the given 'events' ordered according to their start and 1168 end dates. 1169 """ 1170 1171 ordered_events = events[:] 1172 ordered_events.sort() 1173 return ordered_events 1174 1175 def getCalendarPeriod(calendar_start, calendar_end): 1176 1177 """ 1178 Return a calendar period for the given 'calendar_start' and 'calendar_end'. 1179 These parameters can be given as None. 1180 """ 1181 1182 # Re-order the window, if appropriate. 1183 1184 if calendar_start is not None and calendar_end is not None and calendar_start > calendar_end: 1185 calendar_start, calendar_end = calendar_end, calendar_start 1186 1187 return Timespan(calendar_start, calendar_end) 1188 1189 def getConcretePeriod(calendar_start, calendar_end, earliest, latest, resolution): 1190 1191 """ 1192 From the requested 'calendar_start' and 'calendar_end', which may be None, 1193 indicating that no restriction is imposed on the period for each of the 1194 boundaries, use the 'earliest' and 'latest' event months to define a 1195 specific period of interest. 1196 """ 1197 1198 # Define the period as starting with any specified start month or the 1199 # earliest event known, ending with any specified end month or the latest 1200 # event known. 1201 1202 first = calendar_start or earliest 1203 last = calendar_end or latest 1204 1205 # If there is no range of months to show, perhaps because there are no 1206 # events in the requested period, and there was no start or end month 1207 # specified, show only the month indicated by the start or end of the 1208 # requested period. If all events were to be shown but none were found show 1209 # the current month. 1210 1211 if resolution == "date": 1212 get_current = getCurrentDate 1213 else: 1214 get_current = getCurrentMonth 1215 1216 if first is None: 1217 first = last or get_current() 1218 if last is None: 1219 last = first or get_current() 1220 1221 if resolution == "month": 1222 first = first.as_month() 1223 last = last.as_month() 1224 1225 # Permit "expiring" periods (where the start date approaches the end date). 1226 1227 return min(first, last), last 1228 1229 def getCoverage(events, resolution="date"): 1230 1231 """ 1232 Determine the coverage of the given 'events', returning a collection of 1233 timespans, along with a dictionary mapping locations to collections of 1234 slots, where each slot contains a tuple of the form (timespans, events). 1235 """ 1236 1237 all_events = {} 1238 full_coverage = TimespanCollection(resolution) 1239 1240 # Get event details. 1241 1242 for event in events: 1243 event_details = event.getDetails() 1244 1245 # Find the coverage of this period for the event. 1246 1247 # For day views, each location has its own slot, but for month 1248 # views, all locations are pooled together since having separate 1249 # slots for each location can lead to poor usage of vertical space. 1250 1251 if resolution == "datetime": 1252 event_location = event_details.get("location") 1253 else: 1254 event_location = None 1255 1256 # Update the overall coverage. 1257 1258 full_coverage.insert_in_order(event) 1259 1260 # Add a new events list for a new location. 1261 # Locations can be unspecified, thus None refers to all unlocalised 1262 # events. 1263 1264 if not all_events.has_key(event_location): 1265 all_events[event_location] = [TimespanCollection(resolution, [event])] 1266 1267 # Try and fit the event into an events list. 1268 1269 else: 1270 slot = all_events[event_location] 1271 1272 for slot_events in slot: 1273 1274 # Where the event does not overlap with the events in the 1275 # current collection, add it alongside these events. 1276 1277 if not event in slot_events: 1278 slot_events.insert_in_order(event) 1279 break 1280 1281 # Make a new element in the list if the event cannot be 1282 # marked alongside existing events. 1283 1284 else: 1285 slot.append(TimespanCollection(resolution, [event])) 1286 1287 return full_coverage, all_events 1288 1289 def getCoverageScale(coverage): 1290 1291 """ 1292 Return a scale for the given coverage so that the times involved are 1293 exposed. The scale consists of a list of non-overlapping timespans forming 1294 a contiguous period of time. 1295 """ 1296 1297 times = set() 1298 for timespan in coverage: 1299 start, end = timespan.as_limits() 1300 1301 # Add either genuine times or dates converted to times. 1302 1303 if isinstance(start, DateTime): 1304 times.add(start) 1305 else: 1306 times.add(start.as_datetime(None, None, None, None)) 1307 1308 if isinstance(end, DateTime): 1309 times.add(end) 1310 else: 1311 times.add(end.as_date().next_day()) 1312 1313 times = list(times) 1314 times.sort(cmp_dates_as_day_start) 1315 1316 scale = [] 1317 first = 1 1318 start = None 1319 for time in times: 1320 if not first: 1321 scale.append(Timespan(start, time)) 1322 else: 1323 first = 0 1324 start = time 1325 1326 return scale 1327 1328 # Date-related functions. 1329 1330 def cmp_dates_as_day_start(a, b): 1331 1332 """ 1333 Compare dates/datetimes 'a' and 'b' treating dates without time information 1334 as the earliest time in a particular day. 1335 """ 1336 1337 are_equal = a == b 1338 1339 if are_equal: 1340 a2 = a.as_datetime_or_date() 1341 b2 = b.as_datetime_or_date() 1342 1343 if isinstance(a2, Date) and isinstance(b2, DateTime): 1344 return -1 1345 elif isinstance(a2, DateTime) and isinstance(b2, Date): 1346 return 1 1347 1348 return cmp(a, b) 1349 1350 class Convertible: 1351 1352 "Support for converting temporal objects." 1353 1354 def _get_converter(self, resolution): 1355 if resolution == "month": 1356 return lambda x: x and x.as_month() 1357 elif resolution == "date": 1358 return lambda x: x and x.as_date() 1359 elif resolution == "datetime": 1360 return lambda x: x and x.as_datetime_or_date() 1361 else: 1362 return lambda x: x 1363 1364 class Temporal(Convertible): 1365 1366 "A simple temporal representation, common to dates and times." 1367 1368 def __init__(self, data): 1369 self.data = list(data) 1370 1371 def __repr__(self): 1372 return "%s(%r)" % (self.__class__.__name__, self.data) 1373 1374 def __hash__(self): 1375 return hash(self.as_tuple()) 1376 1377 def as_tuple(self): 1378 return tuple(self.data) 1379 1380 def convert(self, resolution): 1381 return self._get_converter(resolution)(self) 1382 1383 def __cmp__(self, other): 1384 1385 """ 1386 The result of comparing this instance with 'other' is derived from a 1387 comparison of the instances' date(time) data at the highest common 1388 resolution, meaning that if a date is compared to a datetime, the 1389 datetime will be considered as a date. Thus, a date and a datetime 1390 referring to the same date will be considered equal. 1391 """ 1392 1393 if not isinstance(other, Temporal): 1394 return NotImplemented 1395 else: 1396 data = self.as_tuple() 1397 other_data = other.as_tuple() 1398 length = min(len(data), len(other_data)) 1399 return cmp(data[:length], other_data[:length]) 1400 1401 def __sub__(self, other): 1402 1403 """ 1404 Return the difference between this object and the 'other' object at the 1405 highest common accuracy of both objects. 1406 """ 1407 1408 if not isinstance(other, Temporal): 1409 return NotImplemented 1410 else: 1411 data = self.as_tuple() 1412 other_data = other.as_tuple() 1413 if len(data) < len(other_data): 1414 return len(self.until(other)) 1415 else: 1416 return len(other.until(self)) 1417 1418 def _until(self, start, end, nextfn, prevfn): 1419 1420 """ 1421 Return a collection of units of time by starting from the given 'start' 1422 and stepping across intervening units until 'end' is reached, using the 1423 given 'nextfn' and 'prevfn' to step from one unit to the next. 1424 """ 1425 1426 current = start 1427 units = [current] 1428 if current < end: 1429 while current < end: 1430 current = nextfn(current) 1431 units.append(current) 1432 elif current > end: 1433 while current > end: 1434 current = prevfn(current) 1435 units.append(current) 1436 return units 1437 1438 def ambiguous(self): 1439 1440 "Only times can be ambiguous." 1441 1442 return 0 1443 1444 class Month(Temporal): 1445 1446 "A simple year-month representation." 1447 1448 def __str__(self): 1449 return "%04d-%02d" % self.as_tuple()[:2] 1450 1451 def as_datetime(self, day, hour, minute, second, zone): 1452 return DateTime(self.as_tuple() + (day, hour, minute, second, zone)) 1453 1454 def as_date(self, day): 1455 if day < 0: 1456 weekday, ndays = self.month_properties() 1457 day = ndays + 1 + day 1458 return Date(self.as_tuple() + (day,)) 1459 1460 def as_month(self): 1461 return self 1462 1463 def year(self): 1464 return self.data[0] 1465 1466 def month(self): 1467 return self.data[1] 1468 1469 def month_properties(self): 1470 1471 """ 1472 Return the weekday of the 1st of the month, along with the number of 1473 days, as a tuple. 1474 """ 1475 1476 year, month = self.as_tuple()[:2] 1477 return calendar.monthrange(year, month) 1478 1479 def month_update(self, n=1): 1480 1481 "Return the month updated by 'n' months." 1482 1483 year, month = self.as_tuple()[:2] 1484 return Month((year + (month - 1 + n) / 12, (month - 1 + n) % 12 + 1)) 1485 1486 update = month_update 1487 1488 def next_month(self): 1489 1490 "Return the month following this one." 1491 1492 return self.month_update(1) 1493 1494 next = next_month 1495 1496 def previous_month(self): 1497 1498 "Return the month preceding this one." 1499 1500 return self.month_update(-1) 1501 1502 previous = previous_month 1503 1504 def months_until(self, end): 1505 1506 "Return the collection of months from this month until 'end'." 1507 1508 return self._until(self.as_month(), end.as_month(), Month.next_month, Month.previous_month) 1509 1510 until = months_until 1511 1512 class Date(Month): 1513 1514 "A simple year-month-day representation." 1515 1516 def constrain(self): 1517 year, month, day = self.as_tuple()[:3] 1518 1519 month = max(min(month, 12), 1) 1520 wd, last_day = calendar.monthrange(year, month) 1521 day = max(min(day, last_day), 1) 1522 1523 self.data[1:3] = month, day 1524 1525 def __str__(self): 1526 return "%04d-%02d-%02d" % self.as_tuple()[:3] 1527 1528 def as_datetime(self, hour, minute, second, zone): 1529 return DateTime(self.as_tuple() + (hour, minute, second, zone)) 1530 1531 def as_date(self): 1532 return self 1533 1534 def as_datetime_or_date(self): 1535 return self 1536 1537 def as_month(self): 1538 return Month(self.data[:2]) 1539 1540 def day(self): 1541 return self.data[2] 1542 1543 def day_update(self, n=1): 1544 1545 "Return the month updated by 'n' days." 1546 1547 delta = datetime.timedelta(n) 1548 dt = datetime.date(*self.as_tuple()[:3]) 1549 dt_new = dt + delta 1550 return Date((dt_new.year, dt_new.month, dt_new.day)) 1551 1552 update = day_update 1553 1554 def next_day(self): 1555 1556 "Return the date following this one." 1557 1558 year, month, day = self.as_tuple()[:3] 1559 _wd, end_day = calendar.monthrange(year, month) 1560 if day == end_day: 1561 if month == 12: 1562 return Date((year + 1, 1, 1)) 1563 else: 1564 return Date((year, month + 1, 1)) 1565 else: 1566 return Date((year, month, day + 1)) 1567 1568 next = next_day 1569 1570 def previous_day(self): 1571 1572 "Return the date preceding this one." 1573 1574 year, month, day = self.as_tuple()[:3] 1575 if day == 1: 1576 if month == 1: 1577 return Date((year - 1, 12, 31)) 1578 else: 1579 _wd, end_day = calendar.monthrange(year, month - 1) 1580 return Date((year, month - 1, end_day)) 1581 else: 1582 return Date((year, month, day - 1)) 1583 1584 previous = previous_day 1585 1586 def days_until(self, end): 1587 1588 "Return the collection of days from this date until 'end'." 1589 1590 return self._until(self.as_date(), end.as_date(), Date.next_day, Date.previous_day) 1591 1592 until = days_until 1593 1594 class DateTime(Date): 1595 1596 "A simple date plus time representation." 1597 1598 def constrain(self): 1599 Date.constrain(self) 1600 1601 hour, minute, second = self.as_tuple()[3:6] 1602 1603 if self.has_time(): 1604 hour = max(min(hour, 23), 0) 1605 minute = max(min(minute, 59), 0) 1606 1607 if second is not None: 1608 second = max(min(second, 60), 0) # support leap seconds 1609 1610 self.data[3:6] = hour, minute, second 1611 1612 def __str__(self): 1613 return Date.__str__(self) + self.time_string() 1614 1615 def time_string(self): 1616 if self.has_time(): 1617 data = self.as_tuple() 1618 time_str = " %02d:%02d" % data[3:5] 1619 if data[5] is not None: 1620 time_str += ":%02d" % data[5] 1621 if data[6] is not None: 1622 time_str += " %s" % data[6] 1623 return time_str 1624 else: 1625 return "" 1626 1627 def as_datetime(self): 1628 return self 1629 1630 def as_date(self): 1631 return Date(self.data[:3]) 1632 1633 def as_datetime_or_date(self): 1634 1635 """ 1636 Return a date for this datetime if fields are missing. Otherwise, return 1637 this datetime itself. 1638 """ 1639 1640 if not self.has_time(): 1641 return self.as_date() 1642 else: 1643 return self 1644 1645 def __cmp__(self, other): 1646 1647 """ 1648 The result of comparing this instance with 'other' is, if both instances 1649 are datetime instances, derived from a comparison of the datetimes 1650 converted to UTC. If one or both datetimes cannot be converted to UTC, 1651 the datetimes are compared using the basic temporal comparison which 1652 compares their raw time data. 1653 """ 1654 1655 this = self 1656 1657 if this.has_time(): 1658 if isinstance(other, DateTime): 1659 if other.has_time(): 1660 this_utc = this.to_utc() 1661 other_utc = other.to_utc() 1662 if this_utc is not None and other_utc is not None: 1663 return cmp(this_utc.as_tuple(), other_utc.as_tuple()) 1664 else: 1665 other = other.padded() 1666 else: 1667 this = this.padded() 1668 1669 return Date.__cmp__(this, other) 1670 1671 def has_time(self): 1672 1673 """ 1674 Return whether this object has any time information. Objects without 1675 time information can refer to the very start of a day. 1676 """ 1677 1678 return self.data[3] is not None and self.data[4] is not None 1679 1680 def time(self): 1681 return self.data[3:] 1682 1683 def seconds(self): 1684 return self.data[5] 1685 1686 def time_zone(self): 1687 return self.data[6] 1688 1689 def set_time_zone(self, value): 1690 self.data[6] = value 1691 1692 def padded(self, empty_value=0): 1693 1694 """ 1695 Return a datetime with missing fields defined as being the given 1696 'empty_value' or 0 if not specified. 1697 """ 1698 1699 data = [] 1700 for x in self.data[:6]: 1701 if x is None: 1702 data.append(empty_value) 1703 else: 1704 data.append(x) 1705 1706 data += self.data[6:] 1707 return DateTime(data) 1708 1709 def to_utc(self): 1710 1711 """ 1712 Return this object converted to UTC, or None if such a conversion is not 1713 defined. 1714 """ 1715 1716 if not self.has_time(): 1717 return None 1718 1719 offset = self.utc_offset() 1720 if offset: 1721 hours, minutes = offset 1722 1723 # Invert the offset to get the correction. 1724 1725 hours, minutes = -hours, -minutes 1726 1727 # Get the components. 1728 1729 hour, minute, second, zone = self.time() 1730 date = self.as_date() 1731 1732 # Add the minutes and hours. 1733 1734 minute += minutes 1735 if minute < 0 or minute > 59: 1736 hour += minute / 60 1737 minute = minute % 60 1738 1739 # NOTE: This makes various assumptions and probably would not work 1740 # NOTE: for general arithmetic. 1741 1742 hour += hours 1743 if hour < 0: 1744 date = date.previous_day() 1745 hour += 24 1746 elif hour > 23: 1747 date = date.next_day() 1748 hour -= 24 1749 1750 return date.as_datetime(hour, minute, second, "UTC") 1751 1752 # Cannot convert. 1753 1754 else: 1755 return None 1756 1757 def utc_offset(self): 1758 1759 "Return the UTC offset in hours and minutes." 1760 1761 zone = self.time_zone() 1762 if not zone: 1763 return None 1764 1765 # Support explicit UTC zones. 1766 1767 if zone == "UTC": 1768 return 0, 0 1769 1770 # Attempt to return a UTC offset where an explicit offset has been set. 1771 1772 match = timezone_offset_regexp.match(zone) 1773 if match: 1774 if match.group("sign") == "-": 1775 sign = -1 1776 else: 1777 sign = 1 1778 1779 hours = int(match.group("hours")) * sign 1780 minutes = int(match.group("minutes") or 0) * sign 1781 return hours, minutes 1782 1783 # Attempt to handle Olson time zone identifiers. 1784 1785 dt = self.as_olson_datetime() 1786 if dt: 1787 seconds = dt.utcoffset().seconds 1788 hours = seconds / 3600 1789 minutes = (seconds % 3600) / 60 1790 return hours, minutes 1791 1792 # Otherwise return None. 1793 1794 return None 1795 1796 def olson_identifier(self): 1797 1798 "Return the Olson identifier from any zone information." 1799 1800 zone = self.time_zone() 1801 if not zone: 1802 return None 1803 1804 # Attempt to match an identifier. 1805 1806 match = timezone_olson_regexp.match(zone) 1807 if match: 1808 return match.group("olson") 1809 else: 1810 return None 1811 1812 def _as_olson_datetime(self, hours=None): 1813 1814 """ 1815 Return a Python datetime object for this datetime interpreted using any 1816 Olson time zone identifier and the given 'hours' offset, raising one of 1817 the pytz exceptions in case of ambiguity. 1818 """ 1819 1820 olson = self.olson_identifier() 1821 if olson and pytz: 1822 tz = pytz.timezone(olson) 1823 data = self.padded().as_tuple()[:6] 1824 dt = datetime.datetime(*data) 1825 1826 # With an hours offset, find a time probably in a previously 1827 # applicable time zone. 1828 1829 if hours is not None: 1830 td = datetime.timedelta(0, hours * 3600) 1831 dt += td 1832 1833 ldt = tz.localize(dt, None) 1834 1835 # With an hours offset, adjust the time to define it within the 1836 # previously applicable time zone but at the presumably intended 1837 # position. 1838 1839 if hours is not None: 1840 ldt -= td 1841 1842 return ldt 1843 else: 1844 return None 1845 1846 def as_olson_datetime(self): 1847 1848 """ 1849 Return a Python datetime object for this datetime interpreted using any 1850 Olson time zone identifier, choosing the time from the zone before the 1851 period of ambiguity. 1852 """ 1853 1854 try: 1855 return self._as_olson_datetime() 1856 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1857 1858 # Try again, using an earlier local time and then stepping forward 1859 # in the chosen zone. 1860 # NOTE: Four hours earlier seems reasonable. 1861 1862 return self._as_olson_datetime(-4) 1863 1864 def ambiguous(self): 1865 1866 "Return whether the time is local and ambiguous." 1867 1868 try: 1869 self._as_olson_datetime() 1870 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1871 return 1 1872 1873 return 0 1874 1875 class Timespan(ActsAsTimespan, Convertible): 1876 1877 """ 1878 A period of time which can be compared against others to check for overlaps. 1879 """ 1880 1881 def __init__(self, start, end): 1882 self.start = start 1883 self.end = end 1884 1885 # NOTE: Should perhaps catch ambiguous time problems elsewhere. 1886 1887 if self.ambiguous() and self.start is not None and self.end is not None and start > end: 1888 self.start, self.end = end, start 1889 1890 def __repr__(self): 1891 return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end) 1892 1893 def __hash__(self): 1894 return hash((self.start, self.end)) 1895 1896 def as_timespan(self): 1897 return self 1898 1899 def as_limits(self): 1900 return self.start, self.end 1901 1902 def ambiguous(self): 1903 return self.start is not None and self.start.ambiguous() or self.end is not None and self.end.ambiguous() 1904 1905 def convert(self, resolution): 1906 return Timespan(*map(self._get_converter(resolution), self.as_limits())) 1907 1908 def is_before(self, a, b): 1909 1910 """ 1911 Return whether 'a' is before 'b'. Since the end datetime of one period 1912 may be the same as the start datetime of another period, and yet the 1913 first period is intended to be concluded by the end datetime and not 1914 overlap with the other period, a different test is employed for datetime 1915 comparisons. 1916 """ 1917 1918 # Datetimes without times can be equal to dates and be considered as 1919 # occurring before those dates. Generally, datetimes should not be 1920 # produced without time information as getDateTime converts such 1921 # datetimes to dates. 1922 1923 if isinstance(a, DateTime) and (isinstance(b, DateTime) or not a.has_time()): 1924 return a <= b 1925 else: 1926 return a < b 1927 1928 def __contains__(self, other): 1929 1930 """ 1931 This instance is considered to contain 'other' if one is not before or 1932 after the other. If this instance overlaps or coincides with 'other', 1933 then 'other' is regarded as belonging to this instance's time period. 1934 """ 1935 1936 return self == other 1937 1938 def __cmp__(self, other): 1939 1940 """ 1941 Return whether this timespan occupies the same period of time as the 1942 'other'. Timespans are considered less than others if their end points 1943 precede the other's start point, and are considered greater than others 1944 if their start points follow the other's end point. 1945 """ 1946 1947 if isinstance(other, ActsAsTimespan): 1948 other = other.as_timespan() 1949 1950 if self.end is not None and other.start is not None and self.is_before(self.end, other.start): 1951 return -1 1952 elif self.start is not None and other.end is not None and self.is_before(other.end, self.start): 1953 return 1 1954 else: 1955 return 0 1956 1957 else: 1958 if self.end is not None and self.is_before(self.end, other): 1959 return -1 1960 elif self.start is not None and self.is_before(other, self.start): 1961 return 1 1962 else: 1963 return 0 1964 1965 class TimespanCollection: 1966 1967 """ 1968 A class providing a list-like interface supporting membership tests at a 1969 particular resolution in order to maintain a collection of non-overlapping 1970 timespans. 1971 """ 1972 1973 def __init__(self, resolution, values=None): 1974 self.resolution = resolution 1975 self.values = values or [] 1976 1977 def as_timespan(self): 1978 return Timespan(*self.as_limits()) 1979 1980 def as_limits(self): 1981 1982 "Return the earliest and latest points in time for this collection." 1983 1984 if not self.values: 1985 return None, None 1986 else: 1987 first, last = self.values[0], self.values[-1] 1988 if isinstance(first, ActsAsTimespan): 1989 first = first.as_timespan().start 1990 if isinstance(last, ActsAsTimespan): 1991 last = last.as_timespan().end 1992 return first, last 1993 1994 def convert(self, value): 1995 if isinstance(value, ActsAsTimespan): 1996 ts = value.as_timespan() 1997 return ts and ts.convert(self.resolution) 1998 else: 1999 return value.convert(self.resolution) 2000 2001 def __iter__(self): 2002 return iter(self.values) 2003 2004 def __len__(self): 2005 return len(self.values) 2006 2007 def __getitem__(self, i): 2008 return self.values[i] 2009 2010 def __setitem__(self, i, value): 2011 self.values[i] = value 2012 2013 def __contains__(self, value): 2014 test_value = self.convert(value) 2015 return test_value in self.values 2016 2017 def append(self, value): 2018 self.values.append(value) 2019 2020 def insert(self, i, value): 2021 self.values.insert(i, value) 2022 2023 def pop(self): 2024 return self.values.pop() 2025 2026 def insert_in_order(self, value): 2027 bisect.insort_left(self, value) 2028 2029 def getCountry(s): 2030 2031 "Find a country code in the given string 's'." 2032 2033 match = country_code_regexp.search(s) 2034 2035 if match: 2036 return match.group("code") 2037 else: 2038 return None 2039 2040 def getDate(s): 2041 2042 "Parse the string 's', extracting and returning a date object." 2043 2044 dt = getDateTime(s) 2045 if dt is not None: 2046 return dt.as_date() 2047 else: 2048 return None 2049 2050 def getDateTime(s): 2051 2052 """ 2053 Parse the string 's', extracting and returning a datetime object where time 2054 information has been given or a date object where time information is 2055 absent. 2056 """ 2057 2058 m = datetime_regexp.search(s) 2059 if m: 2060 groups = list(m.groups()) 2061 2062 # Convert date and time data to integer or None. 2063 2064 return DateTime(map(int_or_none, groups[:6]) + [m.group("zone")]).as_datetime_or_date() 2065 else: 2066 return None 2067 2068 def getDateFromCalendar(s): 2069 2070 """ 2071 Parse the iCalendar format string 's', extracting and returning a date 2072 object. 2073 """ 2074 2075 dt = getDateTimeFromCalendar(s) 2076 if dt is not None: 2077 return dt.as_date() 2078 else: 2079 return None 2080 2081 def getDateTimeFromCalendar(s): 2082 2083 """ 2084 Parse the iCalendar format datetime string 's', extracting and returning a 2085 datetime object where time information has been given or a date object where 2086 time information is absent. 2087 """ 2088 2089 m = datetime_icalendar_regexp.search(s) 2090 if m: 2091 groups = list(m.groups()) 2092 2093 # Convert date and time data to integer or None. 2094 2095 return DateTime(map(int_or_none, groups[:6]) + [m.group("utc") and "UTC" or None]).as_datetime_or_date() 2096 else: 2097 return None 2098 2099 def getDateStrings(s): 2100 2101 "Parse the string 's', extracting and returning all date strings." 2102 2103 start = 0 2104 m = date_regexp.search(s, start) 2105 l = [] 2106 while m: 2107 l.append("-".join(m.groups())) 2108 m = date_regexp.search(s, m.end()) 2109 return l 2110 2111 def getMonth(s): 2112 2113 "Parse the string 's', extracting and returning a month object." 2114 2115 m = month_regexp.search(s) 2116 if m: 2117 return Month(map(int, m.groups())) 2118 else: 2119 return None 2120 2121 def getCurrentDate(): 2122 2123 "Return the current date as a (year, month, day) tuple." 2124 2125 today = datetime.date.today() 2126 return Date((today.year, today.month, today.day)) 2127 2128 def getCurrentMonth(): 2129 2130 "Return the current month as a (year, month) tuple." 2131 2132 today = datetime.date.today() 2133 return Month((today.year, today.month)) 2134 2135 def getCurrentYear(): 2136 2137 "Return the current year." 2138 2139 today = datetime.date.today() 2140 return today.year 2141 2142 # Location-related functions. 2143 2144 class Reference: 2145 2146 "A map reference." 2147 2148 def __init__(self, degrees, minutes=0, seconds=0): 2149 self.degrees = degrees 2150 self.minutes = minutes 2151 self.seconds = seconds 2152 2153 def __repr__(self): 2154 return "Reference(%d, %d, %d)" % (self.degrees, self.minutes, self.seconds) 2155 2156 def __add__(self, other): 2157 if not isinstance(other, Reference): 2158 return NotImplemented 2159 else: 2160 s = sign(self.degrees) 2161 o = sign(other.degrees) 2162 carry, seconds = adc(s * self.seconds, o * other.seconds) 2163 carry, minutes = adc(s * self.minutes, o * other.minutes + carry) 2164 return Reference(self.degrees + other.degrees + carry, minutes, seconds) 2165 2166 def __sub__(self, other): 2167 if not isinstance(other, Reference): 2168 return NotImplemented 2169 else: 2170 return self.__add__(Reference(-other.degrees, other.minutes, other.seconds)) 2171 2172 def _compare(self, op, other): 2173 if not isinstance(other, Reference): 2174 return NotImplemented 2175 else: 2176 return op(self.to_degrees(), other.to_degrees()) 2177 2178 def __eq__(self, other): 2179 return self._compare(operator.eq, other) 2180 2181 def __ne__(self, other): 2182 return self._compare(operator.ne, other) 2183 2184 def __lt__(self, other): 2185 return self._compare(operator.lt, other) 2186 2187 def __le__(self, other): 2188 return self._compare(operator.le, other) 2189 2190 def __gt__(self, other): 2191 return self._compare(operator.gt, other) 2192 2193 def __ge__(self, other): 2194 return self._compare(operator.ge, other) 2195 2196 def to_degrees(self): 2197 return sign(self.degrees) * (abs(self.degrees) + self.minutes / 60.0 + self.seconds / 3600.0) 2198 2199 def to_pixels(self, scale): 2200 return self.to_degrees() * scale 2201 2202 def adc(x, y): 2203 result = x + y 2204 return divmod(result, 60) 2205 2206 def getPositionForReference(latitude, longitude, map_y, map_x, map_x_scale, map_y_scale): 2207 return (longitude - map_x).to_pixels(map_x_scale), (latitude - map_y).to_pixels(map_y_scale) 2208 2209 def getPositionForCentrePoint(position, map_x_scale, map_y_scale): 2210 x, y = position 2211 return x - map_x_scale / 2.0, y - map_y_scale / 2.0 2212 2213 def getMapReference(value): 2214 2215 "Return a map reference by parsing the given 'value'." 2216 2217 return Reference(*map(float, value.split(":"))) 2218 2219 # vim: tabstop=4 expandtab shiftwidth=4 2220 2221 # User interface functions. 2222 2223 def getParameter(request, name, default=None): 2224 2225 """ 2226 Using the given 'request', return the value of the parameter with the given 2227 'name', returning the optional 'default' (or None) if no value was supplied 2228 in the 'request'. 2229 """ 2230 2231 return get_form(request).get(name, [default])[0] 2232 2233 def getQualifiedParameter(request, calendar_name, argname, default=None): 2234 2235 """ 2236 Using the given 'request', 'calendar_name' and 'argname', retrieve the 2237 value of the qualified parameter, returning the optional 'default' (or None) 2238 if no value was supplied in the 'request'. 2239 """ 2240 2241 argname = getQualifiedParameterName(calendar_name, argname) 2242 return getParameter(request, argname, default) 2243 2244 def getQualifiedParameterName(calendar_name, argname): 2245 2246 """ 2247 Return the qualified parameter name using the given 'calendar_name' and 2248 'argname'. 2249 """ 2250 2251 if calendar_name is None: 2252 return argname 2253 else: 2254 return "%s-%s" % (calendar_name, argname) 2255 2256 def getParameterDate(arg): 2257 2258 "Interpret 'arg', recognising keywords and simple arithmetic operations." 2259 2260 n = None 2261 2262 if arg is None: 2263 return None 2264 2265 elif arg.startswith("current"): 2266 date = getCurrentDate() 2267 if len(arg) > 8: 2268 n = int(arg[7:]) 2269 2270 elif arg.startswith("yearstart"): 2271 date = Date((getCurrentYear(), 1, 1)) 2272 if len(arg) > 10: 2273 n = int(arg[9:]) 2274 2275 elif arg.startswith("yearend"): 2276 date = Date((getCurrentYear(), 12, 31)) 2277 if len(arg) > 8: 2278 n = int(arg[7:]) 2279 2280 else: 2281 date = getDate(arg) 2282 2283 if n is not None: 2284 date = date.day_update(n) 2285 2286 return date 2287 2288 def getParameterMonth(arg): 2289 2290 "Interpret 'arg', recognising keywords and simple arithmetic operations." 2291 2292 n = None 2293 2294 if arg is None: 2295 return None 2296 2297 elif arg.startswith("current"): 2298 date = getCurrentMonth() 2299 if len(arg) > 8: 2300 n = int(arg[7:]) 2301 2302 elif arg.startswith("yearstart"): 2303 date = Month((getCurrentYear(), 1)) 2304 if len(arg) > 10: 2305 n = int(arg[9:]) 2306 2307 elif arg.startswith("yearend"): 2308 date = Month((getCurrentYear(), 12)) 2309 if len(arg) > 8: 2310 n = int(arg[7:]) 2311 2312 else: 2313 date = getMonth(arg) 2314 2315 if n is not None: 2316 date = date.month_update(n) 2317 2318 return date 2319 2320 def getFormDate(request, calendar_name, argname): 2321 2322 """ 2323 Return the date from the 'request' for the calendar with the given 2324 'calendar_name' using the parameter having the given 'argname'. 2325 """ 2326 2327 arg = getQualifiedParameter(request, calendar_name, argname) 2328 return getParameterDate(arg) 2329 2330 def getFormMonth(request, calendar_name, argname): 2331 2332 """ 2333 Return the month from the 'request' for the calendar with the given 2334 'calendar_name' using the parameter having the given 'argname'. 2335 """ 2336 2337 arg = getQualifiedParameter(request, calendar_name, argname) 2338 return getParameterMonth(arg) 2339 2340 def getFormDateTriple(request, yeararg, montharg, dayarg): 2341 2342 """ 2343 Return the date from the 'request' for the calendar with the given 2344 'calendar_name' using the parameters having the given 'yeararg', 'montharg' 2345 and 'dayarg' names. 2346 """ 2347 2348 year = getParameter(request, yeararg) 2349 month = getParameter(request, montharg) 2350 day = getParameter(request, dayarg) 2351 if year and month and day: 2352 return Date((int(year), int(month), int(day))) 2353 else: 2354 return None 2355 2356 def getFormMonthPair(request, yeararg, montharg): 2357 2358 """ 2359 Return the month from the 'request' for the calendar with the given 2360 'calendar_name' using the parameters having the given 'yeararg' and 2361 'montharg' names. 2362 """ 2363 2364 year = getParameter(request, yeararg) 2365 month = getParameter(request, montharg) 2366 if year and month: 2367 return Month((int(year), int(month))) 2368 else: 2369 return None 2370 2371 def getFullDateLabel(request, date): 2372 2373 """ 2374 Return the full month plus year label using the given 'request' and 2375 'year_month'. 2376 """ 2377 2378 if not date: 2379 return "" 2380 2381 _ = request.getText 2382 year, month, day = date.as_tuple()[:3] 2383 start_weekday, number_of_days = date.month_properties() 2384 weekday = (start_weekday + day - 1) % 7 2385 day_label = _(getDayLabel(weekday)) 2386 month_label = _(getMonthLabel(month)) 2387 return "%s %s %s %s" % (day_label, day, month_label, year) 2388 2389 def getFullMonthLabel(request, year_month): 2390 2391 """ 2392 Return the full month plus year label using the given 'request' and 2393 'year_month'. 2394 """ 2395 2396 if not year_month: 2397 return "" 2398 2399 _ = request.getText 2400 year, month = year_month.as_tuple()[:2] 2401 month_label = _(getMonthLabel(month)) 2402 return "%s %s" % (month_label, year) 2403 2404 # Page-related functions. 2405 2406 def getPrettyPageName(page): 2407 2408 "Return a nicely formatted title/name for the given 'page'." 2409 2410 title = page.split_title(force=1) 2411 return getPrettyTitle(title) 2412 2413 def linkToPage(request, page, text, query_string=None): 2414 2415 """ 2416 Using 'request', return a link to 'page' with the given link 'text' and 2417 optional 'query_string'. 2418 """ 2419 2420 text = wikiutil.escape(text) 2421 return page.link_to_raw(request, text, query_string) 2422 2423 def getFullPageName(parent, title): 2424 2425 """ 2426 Return a full page name from the given 'parent' page (can be empty or None) 2427 and 'title' (a simple page name). 2428 """ 2429 2430 if parent: 2431 return "%s/%s" % (parent.rstrip("/"), title) 2432 else: 2433 return title 2434 2435 def fillEventPageFromTemplate(template_page, new_page, event_details, category_pagenames): 2436 2437 """ 2438 Using the given 'template_page', complete the 'new_page' by copying the 2439 template and adding the given 'event_details' (a dictionary of event 2440 fields), setting also the 'category_pagenames' to define category 2441 membership. 2442 """ 2443 2444 event_page = EventPage(template_page) 2445 new_event_page = EventPage(new_page) 2446 new_event_page.copyPage(event_page) 2447 2448 if new_event_page.getFormat() == "wiki": 2449 new_event = Event(new_event_page, event_details) 2450 new_event_page.setEvents([new_event]) 2451 new_event_page.setCategoryMembership(category_pagenames) 2452 new_event_page.saveChanges() 2453 2454 # vim: tabstop=4 expandtab shiftwidth=4