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