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