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