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 end = self.details["start"] 909 910 # Make any end time refer to the day instead. 911 912 if isinstance(end, DateTime): 913 end = end.as_date() 914 915 self.details["end"] = end 916 917 def __repr__(self): 918 return "<Event %r %r>" % (self.getSummary(), self.as_limits()) 919 920 def __hash__(self): 921 return hash(self.getSummary()) 922 923 def getPage(self): 924 925 "Return the page describing this event." 926 927 return self.page 928 929 def setPage(self, page): 930 931 "Set the 'page' describing this event." 932 933 self.page = page 934 935 def getSummary(self, event_parent=None): 936 937 """ 938 Return either the given title or summary of the event according to the 939 event details, or a summary made from using the pretty version of the 940 page name. 941 942 If the optional 'event_parent' is specified, any page beneath the given 943 'event_parent' page in the page hierarchy will omit this parent information 944 if its name is used as the summary. 945 """ 946 947 event_details = self.details 948 949 if event_details.has_key("title"): 950 return event_details["title"] 951 elif event_details.has_key("summary"): 952 return event_details["summary"] 953 else: 954 # If appropriate, remove the parent details and "/" character. 955 956 title = self.page.getPageName() 957 958 if event_parent and title.startswith(event_parent): 959 title = title[len(event_parent.rstrip("/")) + 1:] 960 961 return getPrettyTitle(title) 962 963 def getDetails(self): 964 965 "Return the details for this event." 966 967 return self.details 968 969 def setDetails(self, event_details): 970 971 "Set the 'event_details' for this event." 972 973 self.details = event_details 974 975 # Timespan-related methods. 976 977 def __contains__(self, other): 978 return self == other 979 980 def __cmp__(self, other): 981 if isinstance(other, Event): 982 return cmp(self.as_timespan(), other.as_timespan()) 983 else: 984 return cmp(self.as_timespan(), other) 985 986 def as_timespan(self): 987 details = self.details 988 if details.has_key("start") and details.has_key("end"): 989 return Timespan(details["start"], details["end"]) 990 else: 991 return None 992 993 def as_limits(self): 994 ts = self.as_timespan() 995 return ts and ts.as_limits() 996 997 # Obtaining event containers and events from such containers. 998 999 def getEventPages(pages): 1000 1001 "Return a list of events found on the given 'pages'." 1002 1003 # Get real pages instead of result pages. 1004 1005 return map(EventPage, pages) 1006 1007 def getEventResources(sources, calendar_start, calendar_end, request): 1008 1009 """ 1010 Return resource objects for the given 'sources' using the given 1011 'calendar_start' and 'calendar_end' to parameterise requests to the sources, 1012 and the 'request' to access configuration settings in the Wiki. 1013 """ 1014 1015 sources_page = getattr(request.cfg, "event_aggregator_sources_page", "EventSourcesDict") 1016 1017 # Remote sources are accessed via dictionary page definitions. 1018 1019 if request.user.may.read(sources_page): 1020 sources_dict = request.dicts.dict(sources_page) 1021 else: 1022 return [] 1023 1024 # Use dates for the calendar limits. 1025 1026 if isinstance(calendar_start, Date): 1027 pass 1028 elif isinstance(calendar_start, Month): 1029 calendar_start = calendar_start.as_date(1) 1030 1031 if isinstance(calendar_end, Date): 1032 pass 1033 elif isinstance(calendar_end, Month): 1034 calendar_end = calendar_end.as_date(-1) 1035 1036 resources = [] 1037 1038 for source in sources: 1039 try: 1040 url, format = sources_dict[source].split() 1041 1042 # Prevent local file access. 1043 1044 if url.startswith("file:"): 1045 continue 1046 1047 # Parameterise the URL. 1048 1049 url = url.replace("{start}", calendar_start and str(calendar_start) or "") 1050 url = url.replace("{end}", calendar_end and str(calendar_end) or "") 1051 1052 # Get a parser. 1053 1054 if format == "ical" and vCalendar is not None: 1055 parser = vCalendar.parse 1056 resource_cls = EventCalendar 1057 else: 1058 continue 1059 1060 # Access the remote data source. 1061 1062 f = urllib.urlopen(url) 1063 1064 # NOTE: Should look at the metadata first. 1065 1066 uf = codecs.getreader("utf-8")(f) 1067 1068 try: 1069 resources.append(resource_cls(url, parser(uf))) 1070 finally: 1071 f.close() 1072 uf.close() 1073 1074 except (KeyError, ValueError): 1075 pass 1076 1077 return resources 1078 1079 def getEventsFromResources(resources): 1080 1081 "Return a list of events supplied by the given event 'resources'." 1082 1083 events = [] 1084 1085 for resource in resources: 1086 1087 # Get all events described by the resource. 1088 1089 for event in resource.getEvents(): 1090 1091 # Remember the event. 1092 1093 events.append(event) 1094 1095 return events 1096 1097 # Event filtering and limits. 1098 1099 def getEventsInPeriod(events, calendar_period): 1100 1101 """ 1102 Return a collection containing those of the given 'events' which occur 1103 within the given 'calendar_period'. 1104 """ 1105 1106 all_shown_events = [] 1107 1108 for event in events: 1109 1110 # Test for the suitability of the event. 1111 1112 if event.as_timespan() is not None: 1113 1114 # Compare the dates to the requested calendar window, if any. 1115 1116 if event in calendar_period: 1117 all_shown_events.append(event) 1118 1119 return all_shown_events 1120 1121 def getEventLimits(events): 1122 1123 "Return the earliest and latest of the given 'events'." 1124 1125 earliest = None 1126 latest = None 1127 1128 for event in events: 1129 1130 # Test for the suitability of the event. 1131 1132 if event.as_timespan() is not None: 1133 ts = event.as_timespan() 1134 if earliest is None or ts.start < earliest: 1135 earliest = ts.start 1136 if latest is None or ts.end > latest: 1137 latest = ts.end 1138 1139 return earliest, latest 1140 1141 def setEventTimestamps(request, events): 1142 1143 """ 1144 Using 'request', set timestamp details in the details dictionary of each of 1145 the 'events'. 1146 1147 Return the latest timestamp found. 1148 """ 1149 1150 latest = None 1151 1152 for event in events: 1153 event_details = event.getDetails() 1154 event_page = event.getPage() 1155 1156 # Get the initial revision of the page. 1157 1158 revisions = event_page.getRevisions() 1159 event_page_initial = Page(request, event_page.getPageName(), rev=revisions[-1]) 1160 1161 # Get the created and last modified times. 1162 1163 initial_revision = getPageRevision(event_page_initial) 1164 event_details["created"] = initial_revision["timestamp"] 1165 latest_revision = event_page.getPageRevision() 1166 event_details["last-modified"] = latest_revision["timestamp"] 1167 event_details["sequence"] = len(revisions) - 1 1168 event_details["last-comment"] = latest_revision["comment"] 1169 1170 if latest is None or latest < event_details["last-modified"]: 1171 latest = event_details["last-modified"] 1172 1173 return latest 1174 1175 def getOrderedEvents(events): 1176 1177 """ 1178 Return a list with the given 'events' ordered according to their start and 1179 end dates. 1180 """ 1181 1182 ordered_events = events[:] 1183 ordered_events.sort() 1184 return ordered_events 1185 1186 def getCalendarPeriod(calendar_start, calendar_end): 1187 1188 """ 1189 Return a calendar period for the given 'calendar_start' and 'calendar_end'. 1190 These parameters can be given as None. 1191 """ 1192 1193 # Re-order the window, if appropriate. 1194 1195 if calendar_start is not None and calendar_end is not None and calendar_start > calendar_end: 1196 calendar_start, calendar_end = calendar_end, calendar_start 1197 1198 return Timespan(calendar_start, calendar_end) 1199 1200 def getConcretePeriod(calendar_start, calendar_end, earliest, latest, resolution): 1201 1202 """ 1203 From the requested 'calendar_start' and 'calendar_end', which may be None, 1204 indicating that no restriction is imposed on the period for each of the 1205 boundaries, use the 'earliest' and 'latest' event months to define a 1206 specific period of interest. 1207 """ 1208 1209 # Define the period as starting with any specified start month or the 1210 # earliest event known, ending with any specified end month or the latest 1211 # event known. 1212 1213 first = calendar_start or earliest 1214 last = calendar_end or latest 1215 1216 # If there is no range of months to show, perhaps because there are no 1217 # events in the requested period, and there was no start or end month 1218 # specified, show only the month indicated by the start or end of the 1219 # requested period. If all events were to be shown but none were found show 1220 # the current month. 1221 1222 if resolution == "date": 1223 get_current = getCurrentDate 1224 else: 1225 get_current = getCurrentMonth 1226 1227 if first is None: 1228 first = last or get_current() 1229 if last is None: 1230 last = first or get_current() 1231 1232 if resolution == "month": 1233 first = first.as_month() 1234 last = last.as_month() 1235 1236 # Permit "expiring" periods (where the start date approaches the end date). 1237 1238 return min(first, last), last 1239 1240 def getCoverage(events, resolution="date"): 1241 1242 """ 1243 Determine the coverage of the given 'events', returning a collection of 1244 timespans, along with a dictionary mapping locations to collections of 1245 slots, where each slot contains a tuple of the form (timespans, events). 1246 """ 1247 1248 all_events = {} 1249 full_coverage = TimespanCollection(resolution) 1250 1251 # Get event details. 1252 1253 for event in events: 1254 event_details = event.getDetails() 1255 1256 # Find the coverage of this period for the event. 1257 1258 # For day views, each location has its own slot, but for month 1259 # views, all locations are pooled together since having separate 1260 # slots for each location can lead to poor usage of vertical space. 1261 1262 if resolution == "datetime": 1263 event_location = event_details.get("location") 1264 else: 1265 event_location = None 1266 1267 # Update the overall coverage. 1268 1269 full_coverage.insert_in_order(event) 1270 1271 # Add a new events list for a new location. 1272 # Locations can be unspecified, thus None refers to all unlocalised 1273 # events. 1274 1275 if not all_events.has_key(event_location): 1276 all_events[event_location] = [TimespanCollection(resolution, [event])] 1277 1278 # Try and fit the event into an events list. 1279 1280 else: 1281 slot = all_events[event_location] 1282 1283 for slot_events in slot: 1284 1285 # Where the event does not overlap with the events in the 1286 # current collection, add it alongside these events. 1287 1288 if not event in slot_events: 1289 slot_events.insert_in_order(event) 1290 break 1291 1292 # Make a new element in the list if the event cannot be 1293 # marked alongside existing events. 1294 1295 else: 1296 slot.append(TimespanCollection(resolution, [event])) 1297 1298 return full_coverage, all_events 1299 1300 def getCoverageScale(coverage): 1301 1302 """ 1303 Return a scale for the given coverage so that the times involved are 1304 exposed. The scale consists of a list of non-overlapping timespans forming 1305 a contiguous period of time. 1306 """ 1307 1308 times = set() 1309 for timespan in coverage: 1310 start, end = timespan.as_limits() 1311 1312 # Add either genuine times or dates converted to times. 1313 1314 if isinstance(start, DateTime): 1315 times.add(start) 1316 else: 1317 times.add(start.as_start_of_day()) 1318 1319 if isinstance(end, DateTime): 1320 times.add(end) 1321 else: 1322 times.add(end.as_date().next_day()) 1323 1324 times = list(times) 1325 times.sort(cmp_dates_as_day_start) 1326 1327 scale = [] 1328 first = 1 1329 start = None 1330 for time in times: 1331 if not first: 1332 scale.append(Timespan(start, time)) 1333 else: 1334 first = 0 1335 start = time 1336 1337 return scale 1338 1339 # Date-related functions. 1340 1341 def cmp_dates_as_day_start(a, b): 1342 1343 """ 1344 Compare dates/datetimes 'a' and 'b' treating dates without time information 1345 as the earliest time in a particular day. 1346 """ 1347 1348 are_equal = a == b 1349 1350 if are_equal: 1351 a2 = a.as_datetime_or_date() 1352 b2 = b.as_datetime_or_date() 1353 1354 if isinstance(a2, Date) and isinstance(b2, DateTime): 1355 return -1 1356 elif isinstance(a2, DateTime) and isinstance(b2, Date): 1357 return 1 1358 1359 return cmp(a, b) 1360 1361 class Convertible: 1362 1363 "Support for converting temporal objects." 1364 1365 def _get_converter(self, resolution): 1366 if resolution == "month": 1367 return lambda x: x and x.as_month() 1368 elif resolution == "date": 1369 return lambda x: x and x.as_date() 1370 elif resolution == "datetime": 1371 return lambda x: x and x.as_datetime_or_date() 1372 else: 1373 return lambda x: x 1374 1375 class Temporal(Convertible): 1376 1377 "A simple temporal representation, common to dates and times." 1378 1379 def __init__(self, data): 1380 self.data = list(data) 1381 1382 def __repr__(self): 1383 return "%s(%r)" % (self.__class__.__name__, self.data) 1384 1385 def __hash__(self): 1386 return hash(self.as_tuple()) 1387 1388 def as_tuple(self): 1389 return tuple(self.data) 1390 1391 def convert(self, resolution): 1392 return self._get_converter(resolution)(self) 1393 1394 def __cmp__(self, other): 1395 1396 """ 1397 The result of comparing this instance with 'other' is derived from a 1398 comparison of the instances' date(time) data at the highest common 1399 resolution, meaning that if a date is compared to a datetime, the 1400 datetime will be considered as a date. Thus, a date and a datetime 1401 referring to the same date will be considered equal. 1402 """ 1403 1404 if not isinstance(other, Temporal): 1405 return NotImplemented 1406 else: 1407 data = self.as_tuple() 1408 other_data = other.as_tuple() 1409 length = min(len(data), len(other_data)) 1410 return cmp(data[:length], other_data[:length]) 1411 1412 def __sub__(self, other): 1413 1414 """ 1415 Return the difference between this object and the 'other' object at the 1416 highest common accuracy of both objects. 1417 """ 1418 1419 if not isinstance(other, Temporal): 1420 return NotImplemented 1421 else: 1422 data = self.as_tuple() 1423 other_data = other.as_tuple() 1424 if len(data) < len(other_data): 1425 return len(self.until(other)) 1426 else: 1427 return len(other.until(self)) 1428 1429 def _until(self, start, end, nextfn, prevfn): 1430 1431 """ 1432 Return a collection of units of time by starting from the given 'start' 1433 and stepping across intervening units until 'end' is reached, using the 1434 given 'nextfn' and 'prevfn' to step from one unit to the next. 1435 """ 1436 1437 current = start 1438 units = [current] 1439 if current < end: 1440 while current < end: 1441 current = nextfn(current) 1442 units.append(current) 1443 elif current > end: 1444 while current > end: 1445 current = prevfn(current) 1446 units.append(current) 1447 return units 1448 1449 def ambiguous(self): 1450 1451 "Only times can be ambiguous." 1452 1453 return 0 1454 1455 class Month(Temporal): 1456 1457 "A simple year-month representation." 1458 1459 def __str__(self): 1460 return "%04d-%02d" % self.as_tuple()[:2] 1461 1462 def as_datetime(self, day, hour, minute, second, zone): 1463 return DateTime(self.as_tuple() + (day, hour, minute, second, zone)) 1464 1465 def as_date(self, day): 1466 if day < 0: 1467 weekday, ndays = self.month_properties() 1468 day = ndays + 1 + day 1469 return Date(self.as_tuple() + (day,)) 1470 1471 def as_month(self): 1472 return self 1473 1474 def year(self): 1475 return self.data[0] 1476 1477 def month(self): 1478 return self.data[1] 1479 1480 def month_properties(self): 1481 1482 """ 1483 Return the weekday of the 1st of the month, along with the number of 1484 days, as a tuple. 1485 """ 1486 1487 year, month = self.as_tuple()[:2] 1488 return calendar.monthrange(year, month) 1489 1490 def month_update(self, n=1): 1491 1492 "Return the month updated by 'n' months." 1493 1494 year, month = self.as_tuple()[:2] 1495 return Month((year + (month - 1 + n) / 12, (month - 1 + n) % 12 + 1)) 1496 1497 update = month_update 1498 1499 def next_month(self): 1500 1501 "Return the month following this one." 1502 1503 return self.month_update(1) 1504 1505 next = next_month 1506 1507 def previous_month(self): 1508 1509 "Return the month preceding this one." 1510 1511 return self.month_update(-1) 1512 1513 previous = previous_month 1514 1515 def months_until(self, end): 1516 1517 "Return the collection of months from this month until 'end'." 1518 1519 return self._until(self.as_month(), end.as_month(), Month.next_month, Month.previous_month) 1520 1521 until = months_until 1522 1523 class Date(Month): 1524 1525 "A simple year-month-day representation." 1526 1527 def constrain(self): 1528 year, month, day = self.as_tuple()[:3] 1529 1530 month = max(min(month, 12), 1) 1531 wd, last_day = calendar.monthrange(year, month) 1532 day = max(min(day, last_day), 1) 1533 1534 self.data[1:3] = month, day 1535 1536 def __str__(self): 1537 return "%04d-%02d-%02d" % self.as_tuple()[:3] 1538 1539 def as_datetime(self, hour, minute, second, zone): 1540 return DateTime(self.as_tuple() + (hour, minute, second, zone)) 1541 1542 def as_start_of_day(self): 1543 return self.as_datetime(None, None, None, None) 1544 1545 def as_date(self): 1546 return self 1547 1548 def as_datetime_or_date(self): 1549 return self 1550 1551 def as_month(self): 1552 return Month(self.data[:2]) 1553 1554 def day(self): 1555 return self.data[2] 1556 1557 def day_update(self, n=1): 1558 1559 "Return the month updated by 'n' days." 1560 1561 delta = datetime.timedelta(n) 1562 dt = datetime.date(*self.as_tuple()[:3]) 1563 dt_new = dt + delta 1564 return Date((dt_new.year, dt_new.month, dt_new.day)) 1565 1566 update = day_update 1567 1568 def next_day(self): 1569 1570 "Return the date following this one." 1571 1572 year, month, day = self.as_tuple()[:3] 1573 _wd, end_day = calendar.monthrange(year, month) 1574 if day == end_day: 1575 if month == 12: 1576 return Date((year + 1, 1, 1)) 1577 else: 1578 return Date((year, month + 1, 1)) 1579 else: 1580 return Date((year, month, day + 1)) 1581 1582 next = next_day 1583 1584 def previous_day(self): 1585 1586 "Return the date preceding this one." 1587 1588 year, month, day = self.as_tuple()[:3] 1589 if day == 1: 1590 if month == 1: 1591 return Date((year - 1, 12, 31)) 1592 else: 1593 _wd, end_day = calendar.monthrange(year, month - 1) 1594 return Date((year, month - 1, end_day)) 1595 else: 1596 return Date((year, month, day - 1)) 1597 1598 previous = previous_day 1599 1600 def days_until(self, end): 1601 1602 "Return the collection of days from this date until 'end'." 1603 1604 return self._until(self.as_date(), end.as_date(), Date.next_day, Date.previous_day) 1605 1606 until = days_until 1607 1608 class DateTime(Date): 1609 1610 "A simple date plus time representation." 1611 1612 def constrain(self): 1613 Date.constrain(self) 1614 1615 hour, minute, second = self.as_tuple()[3:6] 1616 1617 if self.has_time(): 1618 hour = max(min(hour, 23), 0) 1619 minute = max(min(minute, 59), 0) 1620 1621 if second is not None: 1622 second = max(min(second, 60), 0) # support leap seconds 1623 1624 self.data[3:6] = hour, minute, second 1625 1626 def __str__(self): 1627 return Date.__str__(self) + self.time_string() 1628 1629 def time_string(self): 1630 if self.has_time(): 1631 data = self.as_tuple() 1632 time_str = " %02d:%02d" % data[3:5] 1633 if data[5] is not None: 1634 time_str += ":%02d" % data[5] 1635 if data[6] is not None: 1636 time_str += " %s" % data[6] 1637 return time_str 1638 else: 1639 return "" 1640 1641 def as_datetime(self): 1642 return self 1643 1644 def as_date(self): 1645 return Date(self.data[:3]) 1646 1647 def as_datetime_or_date(self): 1648 1649 """ 1650 Return a date for this datetime if fields are missing. Otherwise, return 1651 this datetime itself. 1652 """ 1653 1654 if not self.has_time(): 1655 return self.as_date() 1656 else: 1657 return self 1658 1659 def __cmp__(self, other): 1660 1661 """ 1662 The result of comparing this instance with 'other' is, if both instances 1663 are datetime instances, derived from a comparison of the datetimes 1664 converted to UTC. If one or both datetimes cannot be converted to UTC, 1665 the datetimes are compared using the basic temporal comparison which 1666 compares their raw time data. 1667 """ 1668 1669 this = self 1670 1671 if this.has_time(): 1672 if isinstance(other, DateTime): 1673 if other.has_time(): 1674 this_utc = this.to_utc() 1675 other_utc = other.to_utc() 1676 if this_utc is not None and other_utc is not None: 1677 return cmp(this_utc.as_tuple(), other_utc.as_tuple()) 1678 else: 1679 other = other.padded() 1680 else: 1681 this = this.padded() 1682 1683 return Date.__cmp__(this, other) 1684 1685 def has_time(self): 1686 1687 """ 1688 Return whether this object has any time information. Objects without 1689 time information can refer to the very start of a day. 1690 """ 1691 1692 return self.data[3] is not None and self.data[4] is not None 1693 1694 def time(self): 1695 return self.data[3:] 1696 1697 def seconds(self): 1698 return self.data[5] 1699 1700 def time_zone(self): 1701 return self.data[6] 1702 1703 def set_time_zone(self, value): 1704 self.data[6] = value 1705 1706 def padded(self, empty_value=0): 1707 1708 """ 1709 Return a datetime with missing fields defined as being the given 1710 'empty_value' or 0 if not specified. 1711 """ 1712 1713 data = [] 1714 for x in self.data[:6]: 1715 if x is None: 1716 data.append(empty_value) 1717 else: 1718 data.append(x) 1719 1720 data += self.data[6:] 1721 return DateTime(data) 1722 1723 def to_utc(self): 1724 1725 """ 1726 Return this object converted to UTC, or None if such a conversion is not 1727 defined. 1728 """ 1729 1730 if not self.has_time(): 1731 return None 1732 1733 offset = self.utc_offset() 1734 if offset: 1735 hours, minutes = offset 1736 1737 # Invert the offset to get the correction. 1738 1739 hours, minutes = -hours, -minutes 1740 1741 # Get the components. 1742 1743 hour, minute, second, zone = self.time() 1744 date = self.as_date() 1745 1746 # Add the minutes and hours. 1747 1748 minute += minutes 1749 if minute < 0 or minute > 59: 1750 hour += minute / 60 1751 minute = minute % 60 1752 1753 # NOTE: This makes various assumptions and probably would not work 1754 # NOTE: for general arithmetic. 1755 1756 hour += hours 1757 if hour < 0: 1758 date = date.previous_day() 1759 hour += 24 1760 elif hour > 23: 1761 date = date.next_day() 1762 hour -= 24 1763 1764 return date.as_datetime(hour, minute, second, "UTC") 1765 1766 # Cannot convert. 1767 1768 else: 1769 return None 1770 1771 def utc_offset(self): 1772 1773 "Return the UTC offset in hours and minutes." 1774 1775 zone = self.time_zone() 1776 if not zone: 1777 return None 1778 1779 # Support explicit UTC zones. 1780 1781 if zone == "UTC": 1782 return 0, 0 1783 1784 # Attempt to return a UTC offset where an explicit offset has been set. 1785 1786 match = timezone_offset_regexp.match(zone) 1787 if match: 1788 if match.group("sign") == "-": 1789 sign = -1 1790 else: 1791 sign = 1 1792 1793 hours = int(match.group("hours")) * sign 1794 minutes = int(match.group("minutes") or 0) * sign 1795 return hours, minutes 1796 1797 # Attempt to handle Olson time zone identifiers. 1798 1799 dt = self.as_olson_datetime() 1800 if dt: 1801 seconds = dt.utcoffset().seconds 1802 hours = seconds / 3600 1803 minutes = (seconds % 3600) / 60 1804 return hours, minutes 1805 1806 # Otherwise return None. 1807 1808 return None 1809 1810 def olson_identifier(self): 1811 1812 "Return the Olson identifier from any zone information." 1813 1814 zone = self.time_zone() 1815 if not zone: 1816 return None 1817 1818 # Attempt to match an identifier. 1819 1820 match = timezone_olson_regexp.match(zone) 1821 if match: 1822 return match.group("olson") 1823 else: 1824 return None 1825 1826 def _as_olson_datetime(self, hours=None): 1827 1828 """ 1829 Return a Python datetime object for this datetime interpreted using any 1830 Olson time zone identifier and the given 'hours' offset, raising one of 1831 the pytz exceptions in case of ambiguity. 1832 """ 1833 1834 olson = self.olson_identifier() 1835 if olson and pytz: 1836 tz = pytz.timezone(olson) 1837 data = self.padded().as_tuple()[:6] 1838 dt = datetime.datetime(*data) 1839 1840 # With an hours offset, find a time probably in a previously 1841 # applicable time zone. 1842 1843 if hours is not None: 1844 td = datetime.timedelta(0, hours * 3600) 1845 dt += td 1846 1847 ldt = tz.localize(dt, None) 1848 1849 # With an hours offset, adjust the time to define it within the 1850 # previously applicable time zone but at the presumably intended 1851 # position. 1852 1853 if hours is not None: 1854 ldt -= td 1855 1856 return ldt 1857 else: 1858 return None 1859 1860 def as_olson_datetime(self): 1861 1862 """ 1863 Return a Python datetime object for this datetime interpreted using any 1864 Olson time zone identifier, choosing the time from the zone before the 1865 period of ambiguity. 1866 """ 1867 1868 try: 1869 return self._as_olson_datetime() 1870 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1871 1872 # Try again, using an earlier local time and then stepping forward 1873 # in the chosen zone. 1874 # NOTE: Four hours earlier seems reasonable. 1875 1876 return self._as_olson_datetime(-4) 1877 1878 def ambiguous(self): 1879 1880 "Return whether the time is local and ambiguous." 1881 1882 try: 1883 self._as_olson_datetime() 1884 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1885 return 1 1886 1887 return 0 1888 1889 class Timespan(ActsAsTimespan, Convertible): 1890 1891 """ 1892 A period of time which can be compared against others to check for overlaps. 1893 """ 1894 1895 def __init__(self, start, end): 1896 self.start = start 1897 self.end = end 1898 1899 # NOTE: Should perhaps catch ambiguous time problems elsewhere. 1900 1901 if self.ambiguous() and self.start is not None and self.end is not None and start > end: 1902 self.start, self.end = end, start 1903 1904 def __repr__(self): 1905 return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end) 1906 1907 def __hash__(self): 1908 return hash((self.start, self.end)) 1909 1910 def as_timespan(self): 1911 return self 1912 1913 def as_limits(self): 1914 return self.start, self.end 1915 1916 def ambiguous(self): 1917 return self.start is not None and self.start.ambiguous() or self.end is not None and self.end.ambiguous() 1918 1919 def convert(self, resolution): 1920 return Timespan(*map(self._get_converter(resolution), self.as_limits())) 1921 1922 def is_before(self, a, b): 1923 1924 """ 1925 Return whether 'a' is before 'b'. Since the end datetime of one period 1926 may be the same as the start datetime of another period, and yet the 1927 first period is intended to be concluded by the end datetime and not 1928 overlap with the other period, a different test is employed for datetime 1929 comparisons. 1930 """ 1931 1932 # Datetimes without times can be equal to dates and be considered as 1933 # occurring before those dates. Generally, datetimes should not be 1934 # produced without time information as getDateTime converts such 1935 # datetimes to dates. 1936 1937 if isinstance(a, DateTime) and (isinstance(b, DateTime) or not a.has_time()): 1938 return a <= b 1939 else: 1940 return a < b 1941 1942 def __contains__(self, other): 1943 1944 """ 1945 This instance is considered to contain 'other' if one is not before or 1946 after the other. If this instance overlaps or coincides with 'other', 1947 then 'other' is regarded as belonging to this instance's time period. 1948 """ 1949 1950 return self == other 1951 1952 def __cmp__(self, other): 1953 1954 """ 1955 Return whether this timespan occupies the same period of time as the 1956 'other'. Timespans are considered less than others if their end points 1957 precede the other's start point, and are considered greater than others 1958 if their start points follow the other's end point. 1959 """ 1960 1961 if isinstance(other, ActsAsTimespan): 1962 other = other.as_timespan() 1963 1964 if self.end is not None and other.start is not None and self.is_before(self.end, other.start): 1965 return -1 1966 elif self.start is not None and other.end is not None and self.is_before(other.end, self.start): 1967 return 1 1968 else: 1969 return 0 1970 1971 else: 1972 if self.end is not None and self.is_before(self.end, other): 1973 return -1 1974 elif self.start is not None and self.is_before(other, self.start): 1975 return 1 1976 else: 1977 return 0 1978 1979 class TimespanCollection: 1980 1981 """ 1982 A class providing a list-like interface supporting membership tests at a 1983 particular resolution in order to maintain a collection of non-overlapping 1984 timespans. 1985 """ 1986 1987 def __init__(self, resolution, values=None): 1988 self.resolution = resolution 1989 self.values = values or [] 1990 1991 def as_timespan(self): 1992 return Timespan(*self.as_limits()) 1993 1994 def as_limits(self): 1995 1996 "Return the earliest and latest points in time for this collection." 1997 1998 if not self.values: 1999 return None, None 2000 else: 2001 first, last = self.values[0], self.values[-1] 2002 if isinstance(first, ActsAsTimespan): 2003 first = first.as_timespan().start 2004 if isinstance(last, ActsAsTimespan): 2005 last = last.as_timespan().end 2006 return first, last 2007 2008 def convert(self, value): 2009 if isinstance(value, ActsAsTimespan): 2010 ts = value.as_timespan() 2011 return ts and ts.convert(self.resolution) 2012 else: 2013 return value.convert(self.resolution) 2014 2015 def __iter__(self): 2016 return iter(self.values) 2017 2018 def __len__(self): 2019 return len(self.values) 2020 2021 def __getitem__(self, i): 2022 return self.values[i] 2023 2024 def __setitem__(self, i, value): 2025 self.values[i] = value 2026 2027 def __contains__(self, value): 2028 test_value = self.convert(value) 2029 return test_value in self.values 2030 2031 def append(self, value): 2032 self.values.append(value) 2033 2034 def insert(self, i, value): 2035 self.values.insert(i, value) 2036 2037 def pop(self): 2038 return self.values.pop() 2039 2040 def insert_in_order(self, value): 2041 bisect.insort_left(self, value) 2042 2043 def getCountry(s): 2044 2045 "Find a country code in the given string 's'." 2046 2047 match = country_code_regexp.search(s) 2048 2049 if match: 2050 return match.group("code") 2051 else: 2052 return None 2053 2054 def getDate(s): 2055 2056 "Parse the string 's', extracting and returning a date object." 2057 2058 dt = getDateTime(s) 2059 if dt is not None: 2060 return dt.as_date() 2061 else: 2062 return None 2063 2064 def getDateTime(s): 2065 2066 """ 2067 Parse the string 's', extracting and returning a datetime object where time 2068 information has been given or a date object where time information is 2069 absent. 2070 """ 2071 2072 m = datetime_regexp.search(s) 2073 if m: 2074 groups = list(m.groups()) 2075 2076 # Convert date and time data to integer or None. 2077 2078 return DateTime(map(int_or_none, groups[:6]) + [m.group("zone")]).as_datetime_or_date() 2079 else: 2080 return None 2081 2082 def getDateFromCalendar(s): 2083 2084 """ 2085 Parse the iCalendar format string 's', extracting and returning a date 2086 object. 2087 """ 2088 2089 dt = getDateTimeFromCalendar(s) 2090 if dt is not None: 2091 return dt.as_date() 2092 else: 2093 return None 2094 2095 def getDateTimeFromCalendar(s): 2096 2097 """ 2098 Parse the iCalendar format datetime string 's', extracting and returning a 2099 datetime object where time information has been given or a date object where 2100 time information is absent. 2101 """ 2102 2103 m = datetime_icalendar_regexp.search(s) 2104 if m: 2105 groups = list(m.groups()) 2106 2107 # Convert date and time data to integer or None. 2108 2109 return DateTime(map(int_or_none, groups[:6]) + [m.group("utc") and "UTC" or None]).as_datetime_or_date() 2110 else: 2111 return None 2112 2113 def getDateStrings(s): 2114 2115 "Parse the string 's', extracting and returning all date strings." 2116 2117 start = 0 2118 m = date_regexp.search(s, start) 2119 l = [] 2120 while m: 2121 l.append("-".join(m.groups())) 2122 m = date_regexp.search(s, m.end()) 2123 return l 2124 2125 def getMonth(s): 2126 2127 "Parse the string 's', extracting and returning a month object." 2128 2129 m = month_regexp.search(s) 2130 if m: 2131 return Month(map(int, m.groups())) 2132 else: 2133 return None 2134 2135 def getCurrentDate(): 2136 2137 "Return the current date as a (year, month, day) tuple." 2138 2139 today = datetime.date.today() 2140 return Date((today.year, today.month, today.day)) 2141 2142 def getCurrentMonth(): 2143 2144 "Return the current month as a (year, month) tuple." 2145 2146 today = datetime.date.today() 2147 return Month((today.year, today.month)) 2148 2149 def getCurrentYear(): 2150 2151 "Return the current year." 2152 2153 today = datetime.date.today() 2154 return today.year 2155 2156 # Location-related functions. 2157 2158 class Reference: 2159 2160 "A map reference." 2161 2162 def __init__(self, degrees, minutes=0, seconds=0): 2163 self.degrees = degrees 2164 self.minutes = minutes 2165 self.seconds = seconds 2166 2167 def __repr__(self): 2168 return "Reference(%d, %d, %d)" % (self.degrees, self.minutes, self.seconds) 2169 2170 def __add__(self, other): 2171 if not isinstance(other, Reference): 2172 return NotImplemented 2173 else: 2174 s = sign(self.degrees) 2175 o = sign(other.degrees) 2176 carry, seconds = adc(s * self.seconds, o * other.seconds) 2177 carry, minutes = adc(s * self.minutes, o * other.minutes + carry) 2178 return Reference(self.degrees + other.degrees + carry, minutes, seconds) 2179 2180 def __sub__(self, other): 2181 if not isinstance(other, Reference): 2182 return NotImplemented 2183 else: 2184 return self.__add__(Reference(-other.degrees, other.minutes, other.seconds)) 2185 2186 def _compare(self, op, other): 2187 if not isinstance(other, Reference): 2188 return NotImplemented 2189 else: 2190 return op(self.to_degrees(), other.to_degrees()) 2191 2192 def __eq__(self, other): 2193 return self._compare(operator.eq, other) 2194 2195 def __ne__(self, other): 2196 return self._compare(operator.ne, other) 2197 2198 def __lt__(self, other): 2199 return self._compare(operator.lt, other) 2200 2201 def __le__(self, other): 2202 return self._compare(operator.le, other) 2203 2204 def __gt__(self, other): 2205 return self._compare(operator.gt, other) 2206 2207 def __ge__(self, other): 2208 return self._compare(operator.ge, other) 2209 2210 def to_degrees(self): 2211 return sign(self.degrees) * (abs(self.degrees) + self.minutes / 60.0 + self.seconds / 3600.0) 2212 2213 def to_pixels(self, scale): 2214 return self.to_degrees() * scale 2215 2216 def adc(x, y): 2217 result = x + y 2218 return divmod(result, 60) 2219 2220 def getPositionForReference(latitude, longitude, map_y, map_x, map_x_scale, map_y_scale): 2221 return (longitude - map_x).to_pixels(map_x_scale), (latitude - map_y).to_pixels(map_y_scale) 2222 2223 def getPositionForCentrePoint(position, map_x_scale, map_y_scale): 2224 x, y = position 2225 return x - map_x_scale / 2.0, y - map_y_scale / 2.0 2226 2227 def getMapReference(value): 2228 2229 "Return a map reference by parsing the given 'value'." 2230 2231 return Reference(*map(float, value.split(":"))) 2232 2233 # vim: tabstop=4 expandtab shiftwidth=4 2234 2235 # User interface functions. 2236 2237 def getParameter(request, name, default=None): 2238 2239 """ 2240 Using the given 'request', return the value of the parameter with the given 2241 'name', returning the optional 'default' (or None) if no value was supplied 2242 in the 'request'. 2243 """ 2244 2245 return get_form(request).get(name, [default])[0] 2246 2247 def getQualifiedParameter(request, calendar_name, argname, default=None): 2248 2249 """ 2250 Using the given 'request', 'calendar_name' and 'argname', retrieve the 2251 value of the qualified parameter, returning the optional 'default' (or None) 2252 if no value was supplied in the 'request'. 2253 """ 2254 2255 argname = getQualifiedParameterName(calendar_name, argname) 2256 return getParameter(request, argname, default) 2257 2258 def getQualifiedParameterName(calendar_name, argname): 2259 2260 """ 2261 Return the qualified parameter name using the given 'calendar_name' and 2262 'argname'. 2263 """ 2264 2265 if calendar_name is None: 2266 return argname 2267 else: 2268 return "%s-%s" % (calendar_name, argname) 2269 2270 def getParameterDate(arg): 2271 2272 "Interpret 'arg', recognising keywords and simple arithmetic operations." 2273 2274 n = None 2275 2276 if arg is None: 2277 return None 2278 2279 elif arg.startswith("current"): 2280 date = getCurrentDate() 2281 if len(arg) > 8: 2282 n = int(arg[7:]) 2283 2284 elif arg.startswith("yearstart"): 2285 date = Date((getCurrentYear(), 1, 1)) 2286 if len(arg) > 10: 2287 n = int(arg[9:]) 2288 2289 elif arg.startswith("yearend"): 2290 date = Date((getCurrentYear(), 12, 31)) 2291 if len(arg) > 8: 2292 n = int(arg[7:]) 2293 2294 else: 2295 date = getDate(arg) 2296 2297 if n is not None: 2298 date = date.day_update(n) 2299 2300 return date 2301 2302 def getParameterMonth(arg): 2303 2304 "Interpret 'arg', recognising keywords and simple arithmetic operations." 2305 2306 n = None 2307 2308 if arg is None: 2309 return None 2310 2311 elif arg.startswith("current"): 2312 date = getCurrentMonth() 2313 if len(arg) > 8: 2314 n = int(arg[7:]) 2315 2316 elif arg.startswith("yearstart"): 2317 date = Month((getCurrentYear(), 1)) 2318 if len(arg) > 10: 2319 n = int(arg[9:]) 2320 2321 elif arg.startswith("yearend"): 2322 date = Month((getCurrentYear(), 12)) 2323 if len(arg) > 8: 2324 n = int(arg[7:]) 2325 2326 else: 2327 date = getMonth(arg) 2328 2329 if n is not None: 2330 date = date.month_update(n) 2331 2332 return date 2333 2334 def getFormDate(request, calendar_name, argname): 2335 2336 """ 2337 Return the date from the 'request' for the calendar with the given 2338 'calendar_name' using the parameter having the given 'argname'. 2339 """ 2340 2341 arg = getQualifiedParameter(request, calendar_name, argname) 2342 return getParameterDate(arg) 2343 2344 def getFormMonth(request, calendar_name, argname): 2345 2346 """ 2347 Return the month from the 'request' for the calendar with the given 2348 'calendar_name' using the parameter having the given 'argname'. 2349 """ 2350 2351 arg = getQualifiedParameter(request, calendar_name, argname) 2352 return getParameterMonth(arg) 2353 2354 def getFormDateTriple(request, yeararg, montharg, dayarg): 2355 2356 """ 2357 Return the date from the 'request' for the calendar with the given 2358 'calendar_name' using the parameters having the given 'yeararg', 'montharg' 2359 and 'dayarg' names. 2360 """ 2361 2362 year = getParameter(request, yeararg) 2363 month = getParameter(request, montharg) 2364 day = getParameter(request, dayarg) 2365 if year and month and day: 2366 return Date((int(year), int(month), int(day))) 2367 else: 2368 return None 2369 2370 def getFormMonthPair(request, yeararg, montharg): 2371 2372 """ 2373 Return the month from the 'request' for the calendar with the given 2374 'calendar_name' using the parameters having the given 'yeararg' and 2375 'montharg' names. 2376 """ 2377 2378 year = getParameter(request, yeararg) 2379 month = getParameter(request, montharg) 2380 if year and month: 2381 return Month((int(year), int(month))) 2382 else: 2383 return None 2384 2385 def getFullDateLabel(request, date): 2386 2387 """ 2388 Return the full month plus year label using the given 'request' and 2389 'year_month'. 2390 """ 2391 2392 if not date: 2393 return "" 2394 2395 _ = request.getText 2396 year, month, day = date.as_tuple()[:3] 2397 start_weekday, number_of_days = date.month_properties() 2398 weekday = (start_weekday + day - 1) % 7 2399 day_label = _(getDayLabel(weekday)) 2400 month_label = _(getMonthLabel(month)) 2401 return "%s %s %s %s" % (day_label, day, month_label, year) 2402 2403 def getFullMonthLabel(request, year_month): 2404 2405 """ 2406 Return the full month plus year label using the given 'request' and 2407 'year_month'. 2408 """ 2409 2410 if not year_month: 2411 return "" 2412 2413 _ = request.getText 2414 year, month = year_month.as_tuple()[:2] 2415 month_label = _(getMonthLabel(month)) 2416 return "%s %s" % (month_label, year) 2417 2418 # Page-related functions. 2419 2420 def getPrettyPageName(page): 2421 2422 "Return a nicely formatted title/name for the given 'page'." 2423 2424 title = page.split_title(force=1) 2425 return getPrettyTitle(title) 2426 2427 def linkToPage(request, page, text, query_string=None): 2428 2429 """ 2430 Using 'request', return a link to 'page' with the given link 'text' and 2431 optional 'query_string'. 2432 """ 2433 2434 text = wikiutil.escape(text) 2435 return page.link_to_raw(request, text, query_string) 2436 2437 def getFullPageName(parent, title): 2438 2439 """ 2440 Return a full page name from the given 'parent' page (can be empty or None) 2441 and 'title' (a simple page name). 2442 """ 2443 2444 if parent: 2445 return "%s/%s" % (parent.rstrip("/"), title) 2446 else: 2447 return title 2448 2449 def fillEventPageFromTemplate(template_page, new_page, event_details, category_pagenames): 2450 2451 """ 2452 Using the given 'template_page', complete the 'new_page' by copying the 2453 template and adding the given 'event_details' (a dictionary of event 2454 fields), setting also the 'category_pagenames' to define category 2455 membership. 2456 """ 2457 2458 event_page = EventPage(template_page) 2459 new_event_page = EventPage(new_page) 2460 new_event_page.copyPage(event_page) 2461 2462 if new_event_page.getFormat() == "wiki": 2463 new_event = Event(new_event_page, event_details) 2464 new_event_page.setEvents([new_event]) 2465 new_event_page.setCategoryMembership(category_pagenames) 2466 new_event_page.saveChanges() 2467 2468 # vim: tabstop=4 expandtab shiftwidth=4