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 datetime 16 import time 17 import re 18 import bisect 19 import operator 20 21 try: 22 set 23 except NameError: 24 from sets import Set as set 25 26 try: 27 import pytz 28 except ImportError: 29 pytz = None 30 31 __version__ = "0.7" 32 33 # Date labels. 34 35 month_labels = ["January", "February", "March", "April", "May", "June", 36 "July", "August", "September", "October", "November", "December"] 37 weekday_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 38 39 # Regular expressions where MoinMoin does not provide the required support. 40 41 category_regexp = None 42 43 # Page parsing. 44 45 definition_list_regexp = re.compile(ur'(?P<wholeterm>^(?P<optcomment>#*)\s+(?P<term>.*?):: )(?P<desc>.*?)$', re.UNICODE | re.MULTILINE) 46 category_membership_regexp = re.compile(ur"^\s*((Category\S+)(\s+Category\S+)*)\s*$", re.MULTILINE | re.UNICODE) 47 48 # Value parsing. 49 50 country_code_regexp = re.compile(ur'(?:^|\W)(?P<code>[A-Z]{2})(?:$|\W+$)', re.UNICODE) 51 52 month_regexp_str = ur'(?P<year>[0-9]{4})-(?P<month>[0-9]{2})' 53 date_regexp_str = ur'(?P<year>[0-9]{4})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})' 54 time_regexp_str = ur'(?P<hour>[0-2][0-9]):(?P<minute>[0-5][0-9])(?::(?P<second>[0-6][0-9]))?' 55 timezone_offset_str = ur'(?P<offset>(UTC)?(?:(?P<sign>[-+])(?P<hours>[0-9]{2})(?::?(?P<minutes>[0-9]{2}))?))' 56 timezone_olson_str = ur'(?P<olson>[a-zA-Z]+(?:/[-_a-zA-Z]+){1,2})' 57 timezone_utc_str = ur'UTC' 58 timezone_regexp_str = ur'(?P<zone>' + timezone_offset_str + '|' + timezone_olson_str + '|' + timezone_utc_str + ')' 59 datetime_regexp_str = date_regexp_str + ur'(?:\s+' + time_regexp_str + ur'(?:\s+' + timezone_regexp_str + ur')?)?' 60 61 month_regexp = re.compile(month_regexp_str, re.UNICODE) 62 date_regexp = re.compile(date_regexp_str, re.UNICODE) 63 time_regexp = re.compile(time_regexp_str, re.UNICODE) 64 datetime_regexp = re.compile(datetime_regexp_str, re.UNICODE) 65 timezone_olson_regexp = re.compile(timezone_olson_str, re.UNICODE) 66 timezone_offset_regexp = re.compile(timezone_offset_str, re.UNICODE) 67 68 verbatim_regexp = re.compile(ur'(?:' 69 ur'<<Verbatim\((?P<verbatim>.*?)\)>>' 70 ur'|' 71 ur'\[\[Verbatim\((?P<verbatim2>.*?)\)\]\]' 72 ur'|' 73 ur'`(?P<monospace>.*?)`' 74 ur'|' 75 ur'{{{(?P<preformatted>.*?)}}}' 76 ur')', re.UNICODE) 77 78 # Utility functions. 79 80 def getCategoryPattern(request): 81 global category_regexp 82 83 try: 84 return request.cfg.cache.page_category_regexact 85 except AttributeError: 86 87 # Use regular expression from MoinMoin 1.7.1 otherwise. 88 89 if category_regexp is None: 90 category_regexp = re.compile(u'^%s$' % ur'(?P<all>Category(?P<key>(?!Template)\S+))', re.UNICODE) 91 return category_regexp 92 93 def int_or_none(x): 94 if x is None: 95 return x 96 else: 97 return int(x) 98 99 def sort_none_first(x, y): 100 if x is None: 101 return -1 102 elif y is None: 103 return 1 104 else: 105 return cmp(x, y) 106 107 # Utility classes and associated functions. 108 109 class Form: 110 111 """ 112 A wrapper preserving MoinMoin 1.8.x (and earlier) behaviour in a 1.9.x 113 environment. 114 """ 115 116 def __init__(self, form): 117 self.form = form 118 119 def get(self, name, default=None): 120 values = self.form.getlist(name) 121 if not values: 122 return default 123 else: 124 return values 125 126 def __getitem__(self, name): 127 return self.form.getlist(name) 128 129 class ActionSupport: 130 131 """ 132 Work around disruptive MoinMoin changes in 1.9, and also provide useful 133 convenience methods. 134 """ 135 136 def get_form(self): 137 return get_form(self.request) 138 139 def _get_selected(self, value, input_value): 140 141 """ 142 Return the HTML attribute text indicating selection of an option (or 143 otherwise) if 'value' matches 'input_value'. 144 """ 145 146 return input_value is not None and value == input_value and 'selected="selected"' or '' 147 148 def _get_selected_for_list(self, value, input_values): 149 150 """ 151 Return the HTML attribute text indicating selection of an option (or 152 otherwise) if 'value' matches one of the 'input_values'. 153 """ 154 155 return value in input_values and 'selected="selected"' or '' 156 157 def _get_input(self, form, name, default=None): 158 159 """ 160 Return the input from 'form' having the given 'name', returning either 161 the input converted to an integer or the given 'default' (optional, None 162 if not specified). 163 """ 164 165 value = form.get(name, [None])[0] 166 if not value: # true if 0 obtained 167 return default 168 else: 169 return int(value) 170 171 def get_month_lists(self, default_as_current=0): 172 173 """ 174 Return two lists of HTML element definitions corresponding to the start 175 and end month selection controls, with months selected according to any 176 values that have been specified via request parameters. 177 """ 178 179 _ = self._ 180 form = self.get_form() 181 182 # Initialise month lists. 183 184 start_month_list = [] 185 end_month_list = [] 186 187 start_month = self._get_input(form, "start-month", default_as_current and getCurrentMonth().month() or None) 188 end_month = self._get_input(form, "end-month", start_month) 189 190 # Prepare month lists, selecting specified months. 191 192 if not default_as_current: 193 start_month_list.append('<option value=""></option>') 194 end_month_list.append('<option value=""></option>') 195 196 for month in range(1, 13): 197 month_label = _(getMonthLabel(month)) 198 selected = self._get_selected(month, start_month) 199 start_month_list.append('<option value="%02d" %s>%s</option>' % (month, selected, month_label)) 200 selected = self._get_selected(month, end_month) 201 end_month_list.append('<option value="%02d" %s>%s</option>' % (month, selected, month_label)) 202 203 return start_month_list, end_month_list 204 205 def get_year_defaults(self, default_as_current=0): 206 207 "Return defaults for the start and end years." 208 209 form = self.get_form() 210 211 start_year_default = form.get("start-year", [default_as_current and getCurrentYear() or ""])[0] 212 end_year_default = form.get("end-year", [default_as_current and start_year_default or ""])[0] 213 214 return start_year_default, end_year_default 215 216 def get_day_defaults(self, default_as_current=0): 217 218 "Return defaults for the start and end days." 219 220 form = self.get_form() 221 222 start_day_default = form.get("start-day", [default_as_current and getCurrentDate().day() or ""])[0] 223 end_day_default = form.get("end-day", [default_as_current and start_day_default or ""])[0] 224 225 return start_day_default, end_day_default 226 227 def get_form(request): 228 229 "Work around disruptive MoinMoin changes in 1.9." 230 231 if hasattr(request, "values"): 232 return Form(request.values) 233 else: 234 return request.form 235 236 class send_headers: 237 238 """ 239 A wrapper to preserve MoinMoin 1.8.x (and earlier) request behaviour in a 240 1.9.x environment. 241 """ 242 243 def __init__(self, request): 244 self.request = request 245 246 def __call__(self, headers): 247 for header in headers: 248 parts = header.split(":") 249 self.request.headers.add(parts[0], ":".join(parts[1:])) 250 251 # Textual representations. 252 253 def getHTTPTimeString(tmtuple): 254 return "%s, %02d %s %04d %02d:%02d:%02d GMT" % ( 255 weekday_labels[tmtuple.tm_wday], 256 tmtuple.tm_mday, 257 month_labels[tmtuple.tm_mon -1], # zero-based labels 258 tmtuple.tm_year, 259 tmtuple.tm_hour, 260 tmtuple.tm_min, 261 tmtuple.tm_sec 262 ) 263 264 def getSimpleWikiText(text): 265 266 """ 267 Return the plain text representation of the given 'text' which may employ 268 certain Wiki syntax features, such as those providing verbatim or monospaced 269 text. 270 """ 271 272 # NOTE: Re-implementing support for verbatim text and linking avoidance. 273 274 return "".join([s for s in verbatim_regexp.split(text) if s is not None]) 275 276 def getEncodedWikiText(text): 277 278 "Encode the given 'text' in a verbatim representation." 279 280 return "<<Verbatim(%s)>>" % text 281 282 def getPrettyTitle(title): 283 284 "Return a nicely formatted version of the given 'title'." 285 286 return title.replace("_", " ").replace("/", u" ? ") 287 288 def getMonthLabel(month): 289 290 "Return an unlocalised label for the given 'month'." 291 292 return month_labels[month - 1] # zero-based labels 293 294 def getDayLabel(weekday): 295 296 "Return an unlocalised label for the given 'weekday'." 297 298 return weekday_labels[weekday] 299 300 # Action support functions. 301 302 def getPageRevision(page): 303 304 "Return the revision details dictionary for the given 'page'." 305 306 # From Page.edit_info... 307 308 if hasattr(page, "editlog_entry"): 309 line = page.editlog_entry() 310 else: 311 line = page._last_edited(page.request) # MoinMoin 1.5.x and 1.6.x 312 313 # Similar to Page.mtime_usecs behaviour... 314 315 if line: 316 timestamp = line.ed_time_usecs 317 mtime = wikiutil.version2timestamp(long(timestamp)) # must be long for py 2.2.x 318 comment = line.comment 319 else: 320 mtime = 0 321 comment = "" 322 323 return {"timestamp" : time.gmtime(mtime), "comment" : comment} 324 325 # Category discovery and searching. 326 327 def getCategories(request): 328 329 """ 330 From the AdvancedSearch macro, return a list of category page names using 331 the given 'request'. 332 """ 333 334 # This will return all pages with "Category" in the title. 335 336 cat_filter = getCategoryPattern(request).search 337 return request.rootpage.getPageList(filter=cat_filter) 338 339 def getCategoryMapping(category_pagenames, request): 340 341 """ 342 For the given 'category_pagenames' return a list of tuples of the form 343 (category name, category page name) using the given 'request'. 344 """ 345 346 cat_pattern = getCategoryPattern(request) 347 mapping = [] 348 for pagename in category_pagenames: 349 name = cat_pattern.match(pagename).group("key") 350 if name != "Category": 351 mapping.append((name, pagename)) 352 mapping.sort() 353 return mapping 354 355 def getCategoryPages(pagename, request): 356 357 """ 358 Return the pages associated with the given category 'pagename' using the 359 'request'. 360 """ 361 362 query = search.QueryParser().parse_query('category:%s' % pagename) 363 results = search.searchPages(request, query, "page_name") 364 365 cat_pattern = getCategoryPattern(request) 366 pages = [] 367 for page in results.hits: 368 if not cat_pattern.match(page.page_name): 369 pages.append(page) 370 return pages 371 372 # The main activity functions. 373 374 class EventPage: 375 376 "An event page." 377 378 def __init__(self, page): 379 self.page = page 380 self.events = None 381 self.body = None 382 self.categories = None 383 384 def copyPage(self, page): 385 386 "Copy the body of the given 'page'." 387 388 self.body = page.getBody() 389 390 def getPageURL(self, request): 391 392 "Using 'request', return the URL of this page." 393 394 return request.getQualifiedURL(self.page.url(request, relative=0)) 395 396 def getFormat(self): 397 398 "Get the format used on this page." 399 400 return self.page.pi["format"] 401 402 def getRevisions(self): 403 404 "Return a list of page revisions." 405 406 return self.page.getRevList() 407 408 def getPageRevision(self): 409 410 "Return the revision details dictionary for this page." 411 412 return getPageRevision(self.page) 413 414 def getPageName(self): 415 416 "Return the page name." 417 418 return self.page.page_name 419 420 def getPrettyPageName(self): 421 422 "Return a nicely formatted title/name for this page." 423 424 return getPrettyPageName(self.page) 425 426 def getBody(self): 427 428 "Get the current page body." 429 430 if self.body is None: 431 self.body = self.page.get_raw_body() 432 return self.body 433 434 def getEvents(self): 435 436 "Return a list of events from this page." 437 438 if self.events is None: 439 details = {} 440 self.events = [Event(self, details)] 441 442 if self.getFormat() == "wiki": 443 for match in definition_list_regexp.finditer(self.getBody()): 444 445 # Skip commented-out items. 446 447 if match.group("optcomment"): 448 continue 449 450 # Permit case-insensitive list terms. 451 452 term = match.group("term").lower() 453 desc = match.group("desc") 454 455 # Special value type handling. 456 457 # Dates. 458 459 if term in ("start", "end"): 460 desc = getDateTime(desc) 461 462 # Lists (whose elements may be quoted). 463 464 elif term in ("topics", "categories"): 465 desc = [getSimpleWikiText(value.strip()) for value in desc.split(",") if value.strip()] 466 467 # Labels which may well be quoted. 468 469 elif term in ("title", "summary", "description", "location"): 470 desc = getSimpleWikiText(desc) 471 472 if desc is not None: 473 474 # Handle apparent duplicates by creating a new set of 475 # details. 476 477 if details.has_key(term): 478 479 # Make a new event. 480 481 details = {} 482 self.events.append(Event(self, details)) 483 484 details[term] = desc 485 486 return self.events 487 488 def setEvents(self, events): 489 490 "Set the given 'events' on this page." 491 492 self.events = events 493 494 def getCategoryMembership(self): 495 496 "Get the category names from this page." 497 498 if self.categories is None: 499 body = self.getBody() 500 match = category_membership_regexp.search(body) 501 self.categories = match.findall().split() 502 503 return self.categories 504 505 def setCategoryMembership(self, category_names): 506 507 """ 508 Set the category membership for the page using the specified 509 'category_names'. 510 """ 511 512 self.categories = category_names 513 514 def flushEventDetails(self): 515 516 "Flush the current event details to this page's body text." 517 518 new_body_parts = [] 519 end_of_last_match = 0 520 body = self.getBody() 521 522 events = iter(self.getEvents()) 523 524 event = events.next() 525 event_details = event.getDetails() 526 replaced_terms = set() 527 528 for match in definition_list_regexp.finditer(body): 529 530 # Permit case-insensitive list terms. 531 532 term = match.group("term").lower() 533 desc = match.group("desc") 534 535 # Check that the term has not already been substituted. If so, 536 # get the next event. 537 538 if term in replaced_terms: 539 try: 540 event = events.next() 541 542 # No more events. 543 544 except StopIteration: 545 break 546 547 event_details = event.getDetails() 548 replaced_terms = set() 549 550 # Add preceding text to the new body. 551 552 new_body_parts.append(body[end_of_last_match:match.start()]) 553 554 # Get the matching regions, adding the term to the new body. 555 556 new_body_parts.append(match.group("wholeterm")) 557 558 # Special value type handling. 559 560 if event_details.has_key(term): 561 562 # Dates. 563 564 if term in ("start", "end"): 565 desc = desc.replace("YYYY-MM-DD", str(event_details[term])) 566 567 # Lists (whose elements may be quoted). 568 569 elif term in ("topics", "categories"): 570 desc = ", ".join([getEncodedWikiText(item) for item in event_details[term]]) 571 572 # Labels which must be quoted. 573 574 elif term in ("title", "summary"): 575 desc = getEncodedWikiText(event_details[term]) 576 577 # Text which need not be quoted, but it will be Wiki text. 578 579 elif term in ("description", "link", "location"): 580 desc = event_details[term] 581 582 replaced_terms.add(term) 583 584 # Add the replaced value. 585 586 new_body_parts.append(desc) 587 588 # Remember where in the page has been processed. 589 590 end_of_last_match = match.end() 591 592 # Write the rest of the page. 593 594 new_body_parts.append(body[end_of_last_match:]) 595 596 self.body = "".join(new_body_parts) 597 598 def flushCategoryMembership(self): 599 600 "Flush the category membership to the page body." 601 602 body = self.getBody() 603 category_names = self.getCategoryMembership() 604 match = category_membership_regexp.search(body) 605 606 if match: 607 self.body = "".join([body[:match.start()], " ".join(category_names), body[match.end():]]) 608 609 def saveChanges(self): 610 611 "Save changes to the event." 612 613 self.flushEventDetails() 614 self.flushCategoryMembership() 615 self.page.saveText(self.getBody(), 0) 616 617 def linkToPage(self, request, text, query_string=None): 618 619 """ 620 Using 'request', return a link to this page with the given link 'text' 621 and optional 'query_string'. 622 """ 623 624 return linkToPage(request, self.page, text, query_string) 625 626 class Event: 627 628 "A description of an event." 629 630 def __init__(self, page, details): 631 self.page = page 632 self.details = details 633 634 def getPage(self): 635 636 "Return the page describing this event." 637 638 return self.page 639 640 def setPage(self, page): 641 642 "Set the 'page' describing this event." 643 644 self.page = page 645 646 def getSummary(self, event_parent=None): 647 648 """ 649 Return either the given title or summary of the event according to the 650 event details, or a summary made from using the pretty version of the 651 page name. 652 653 If the optional 'event_parent' is specified, any page beneath the given 654 'event_parent' page in the page hierarchy will omit this parent information 655 if its name is used as the summary. 656 """ 657 658 event_details = self.details 659 660 if event_details.has_key("title"): 661 return event_details["title"] 662 elif event_details.has_key("summary"): 663 return event_details["summary"] 664 else: 665 # If appropriate, remove the parent details and "/" character. 666 667 title = self.page.getPageName() 668 669 if event_parent and title.startswith(event_parent): 670 title = title[len(event_parent.rstrip("/")) + 1:] 671 672 return getPrettyTitle(title) 673 674 def getDetails(self): 675 676 "Return the details for this event." 677 678 return self.details 679 680 def setDetails(self, event_details): 681 682 "Set the 'event_details' for this event." 683 684 self.details = event_details 685 686 # Timespan-related methods. 687 688 def __contains__(self, other): 689 return self == other 690 691 def __cmp__(self, other): 692 if isinstance(other, Event): 693 return cmp(self.as_timespan(), other.as_timespan()) 694 else: 695 return cmp(self.as_timespan(), other) 696 697 def as_timespan(self): 698 details = self.details 699 if details.has_key("start") and details.has_key("end"): 700 return Timespan(details["start"], details["end"]) 701 else: 702 return None 703 704 def as_times(self): 705 return self.as_timespan().as_times() 706 707 def getEvents(request, category_names, calendar_start=None, calendar_end=None, resolution="month"): 708 709 """ 710 Using the 'request', generate a list of events found on pages belonging to 711 the specified 'category_names', using the optional 'calendar_start' and 712 'calendar_end' values to indicate a window of interest. 713 714 The optional 'resolution' determines the unit of time used in providing the 715 results: 716 717 * a list of events 718 * a dictionary mapping time units to event lists (within the window of 719 interest), usable as a kind of index to groups of events 720 * a list of all events within the window of interest 721 * the earliest time value of an event within the window of interest 722 * the latest time value of an event within the window of interest. 723 """ 724 725 # Dates need to comply with the requested resolution. 726 # Here, None values need to be preserved when converting. 727 728 if resolution == "month": 729 convert = lambda x: x and x.as_month() 730 get_values = lambda x, y: x.months_until(y) 731 else: 732 convert = lambda x: x and x.as_date() 733 get_values = lambda x, y: x.days_until(y) 734 735 # Re-order the window, if appropriate. 736 737 if calendar_start is not None and calendar_end is not None and calendar_start > calendar_end: 738 calendar_start, calendar_end = map(convert, (calendar_end, calendar_start)) 739 740 # Otherwise, just convert the calendar limits. 741 742 else: 743 calendar_start, calendar_end = map(convert, (calendar_start, calendar_end)) 744 745 calendar_period = Timespan(calendar_start, calendar_end) 746 747 events = [] 748 shown_events = {} 749 all_shown_events = [] 750 processed_pages = set() 751 752 earliest = None 753 latest = None 754 755 for category_name in category_names: 756 757 # Get the pages and page names in the category. 758 759 pages_in_category = getCategoryPages(category_name, request) 760 761 # Visit each page in the category. 762 763 for page_in_category in pages_in_category: 764 pagename = page_in_category.page_name 765 766 # Only process each page once. 767 768 if pagename in processed_pages: 769 continue 770 else: 771 processed_pages.add(pagename) 772 773 # Get a real page, not a result page. 774 775 event_page = EventPage(Page(request, pagename)) 776 777 # Get all events described in the page. 778 779 for event in event_page.getEvents(): 780 event_details = event.getDetails() 781 782 # Remember the event. 783 784 events.append(event) 785 786 # Test for the suitability of the event. 787 788 if event.as_timespan() is not None: 789 start, end = map(convert, event.as_timespan().as_times()) 790 791 # Compare the dates to the requested calendar window, if any. 792 793 if event in calendar_period: 794 795 all_shown_events.append(event) 796 797 if earliest is None or start < earliest: 798 earliest = start 799 if latest is None or end > latest: 800 latest = end 801 802 # Store the event in the time-specific dictionary. 803 804 first = max(start, calendar_start or start) 805 last = min(end, calendar_end or end) 806 807 for event_time_value in get_values(first, last): 808 if not shown_events.has_key(event_time_value): 809 shown_events[event_time_value] = [] 810 shown_events[event_time_value].append(event) 811 812 return events, shown_events, all_shown_events, earliest, latest 813 814 def setEventTimestamps(request, events): 815 816 """ 817 Using 'request', set timestamp details in the details dictionary of each of 818 the 'events'. 819 820 Return the latest timestamp found. 821 """ 822 823 latest = None 824 825 for event in events: 826 event_details = event.getDetails() 827 event_page = event.getPage() 828 829 # Get the initial revision of the page. 830 831 revisions = event_page.getRevisions() 832 event_page_initial = Page(request, event_page.getPageName(), rev=revisions[-1]) 833 834 # Get the created and last modified times. 835 836 initial_revision = getPageRevision(event_page_initial) 837 event_details["created"] = initial_revision["timestamp"] 838 latest_revision = event_page.getPageRevision() 839 event_details["last-modified"] = latest_revision["timestamp"] 840 event_details["sequence"] = len(revisions) - 1 841 event_details["last-comment"] = latest_revision["comment"] 842 843 if latest is None or latest < event_details["last-modified"]: 844 latest = event_details["last-modified"] 845 846 return latest 847 848 def getOrderedEvents(events): 849 850 """ 851 Return a list with the given 'events' ordered according to their start and 852 end dates. 853 """ 854 855 ordered_events = events[:] 856 ordered_events.sort() 857 return ordered_events 858 859 def getConcretePeriod(calendar_start, calendar_end, earliest, latest): 860 861 """ 862 From the requested 'calendar_start' and 'calendar_end', which may be None, 863 indicating that no restriction is imposed on the period for each of the 864 boundaries, use the 'earliest' and 'latest' event months to define a 865 specific period of interest. 866 """ 867 868 # Define the period as starting with any specified start month or the 869 # earliest event known, ending with any specified end month or the latest 870 # event known. 871 872 first = calendar_start or earliest 873 last = calendar_end or latest 874 875 # If there is no range of months to show, perhaps because there are no 876 # events in the requested period, and there was no start or end month 877 # specified, show only the month indicated by the start or end of the 878 # requested period. If all events were to be shown but none were found show 879 # the current month. 880 881 if isinstance(first, Date): 882 get_current = getCurrentDate 883 else: 884 get_current = getCurrentMonth 885 886 if first is None: 887 first = last or get_current() 888 if last is None: 889 last = first or get_current() 890 891 # Permit "expiring" periods (where the start date approaches the end date). 892 893 return min(first, last), last 894 895 def getCoverage(start, end, events, resolution="date"): 896 897 """ 898 Within the period defined by the 'start' and 'end' dates, determine the 899 coverage of the days in the period by the given 'events', returning a 900 collection of timespans, along with a dictionary mapping locations to 901 collections of slots, where each slot contains a tuple of the form 902 (timespans, events). 903 """ 904 905 all_events = {} 906 full_coverage = TimespanCollection(resolution) 907 coverage_period = full_coverage.convert(Timespan(start, end)) 908 909 # Get event details. 910 911 for event in events: 912 event_details = event.getDetails() 913 914 # Test for the event in the period. 915 916 if event in coverage_period: 917 918 # Find the coverage of this period for the event. 919 920 event_location = event_details.get("location") 921 922 # Update the overall coverage. 923 924 full_coverage.insert_in_order(event) 925 926 # Add a new events list for a new location. 927 # Locations can be unspecified, thus None refers to all unlocalised 928 # events. 929 930 if not all_events.has_key(event_location): 931 all_events[event_location] = [TimespanCollection(resolution, [event])] 932 933 # Try and fit the event into an events list. 934 935 else: 936 slot = all_events[event_location] 937 938 for slot_events in slot: 939 940 # Where the event does not overlap with the current 941 # element, add it alongside existing events. 942 # NOTE: Need to use the resolution when testing for overlaps. 943 944 if not event in slot_events: 945 slot_events.insert_in_order(event) 946 break 947 948 # Make a new element in the list if the event cannot be 949 # marked alongside existing events. 950 951 else: 952 slot.append(TimespanCollection(resolution, [event])) 953 954 return full_coverage, all_events 955 956 def getCoverageScale(coverage): 957 958 """ 959 Return a scale for the given coverage so that the times involved are 960 exposed. The scale consists of a list of non-overlapping timespans forming 961 a contiguous period of time. 962 """ 963 964 times = set() 965 for timespan in coverage: 966 start, end = timespan.as_times() 967 times.add(start) 968 times.add(end) 969 times = list(times) 970 times.sort() 971 972 scale = [] 973 first = 1 974 start = None 975 for time in times: 976 if not first: 977 scale.append(Timespan(start, time)) 978 else: 979 first = 0 980 start = time 981 982 return scale 983 984 # Date-related functions. 985 986 class Period: 987 988 "A simple period of time." 989 990 def __init__(self, data): 991 self.data = data 992 993 def months(self): 994 return self.data[0] * 12 + self.data[1] 995 996 class Temporal: 997 998 "A simple temporal representation, common to dates and times." 999 1000 def __init__(self, data): 1001 self.data = list(data) 1002 1003 def __repr__(self): 1004 return "%s(%r)" % (self.__class__.__name__, self.data) 1005 1006 def __hash__(self): 1007 return hash(self.as_tuple()) 1008 1009 def as_tuple(self): 1010 return tuple(self.data) 1011 1012 def __cmp__(self, other): 1013 if not isinstance(other, Temporal): 1014 return NotImplemented 1015 else: 1016 data = self.as_tuple() 1017 other_data = other.as_tuple() 1018 length = min(len(data), len(other_data)) 1019 return cmp(data[:length], other_data[:length]) 1020 1021 def until(self, start, end, nextfn, prevfn): 1022 1023 """ 1024 Return a collection of units of time by starting from the given 'start' 1025 and stepping across intervening units until 'end' is reached, using the 1026 given 'nextfn' and 'prevfn' to step from one unit to the next. 1027 """ 1028 1029 current = start 1030 units = [current] 1031 if current < end: 1032 while current < end: 1033 current = nextfn(current) 1034 units.append(current) 1035 elif current > end: 1036 while current > end: 1037 current = prevfn(current) 1038 units.append(current) 1039 return units 1040 1041 class Month(Temporal): 1042 1043 "A simple year-month representation." 1044 1045 def __str__(self): 1046 return "%04d-%02d" % self.as_tuple()[:2] 1047 1048 def as_datetime(self, day, hour, minute, second, zone): 1049 return DateTime(self.as_tuple() + (day, hour, minute, second, zone)) 1050 1051 def as_date(self, day): 1052 return Date(self.as_tuple() + (day,)) 1053 1054 def as_month(self): 1055 return self 1056 1057 def year(self): 1058 return self.data[0] 1059 1060 def month(self): 1061 return self.data[1] 1062 1063 def month_properties(self): 1064 1065 """ 1066 Return the weekday of the 1st of the month, along with the number of 1067 days, as a tuple. 1068 """ 1069 1070 year, month = self.as_tuple()[:2] 1071 return calendar.monthrange(year, month) 1072 1073 def month_update(self, n=1): 1074 1075 "Return the month updated by 'n' months." 1076 1077 year, month = self.as_tuple()[:2] 1078 return Month((year + (month - 1 + n) / 12, (month - 1 + n) % 12 + 1)) 1079 1080 def next_month(self): 1081 1082 "Return the month following this one." 1083 1084 return self.month_update(1) 1085 1086 def previous_month(self): 1087 1088 "Return the month preceding this one." 1089 1090 return self.month_update(-1) 1091 1092 def __sub__(self, start): 1093 1094 """ 1095 Return the difference in years and months between this month and the 1096 'start' month as a period. 1097 """ 1098 1099 return Period([(x - y) for x, y in zip(self.data, start.data)]) 1100 1101 def months_until(self, end): 1102 1103 "Return the collection of months from this month until 'end'." 1104 1105 return self.until(self.as_month(), end.as_month(), Month.next_month, Month.previous_month) 1106 1107 class Date(Month): 1108 1109 "A simple year-month-day representation." 1110 1111 def constrain(self): 1112 year, month, day = self.as_tuple()[:3] 1113 1114 month = max(min(month, 12), 1) 1115 wd, last_day = calendar.monthrange(year, month) 1116 day = max(min(day, last_day), 1) 1117 1118 self.data[1:3] = month, day 1119 1120 def __str__(self): 1121 return "%04d-%02d-%02d" % self.as_tuple()[:3] 1122 1123 def as_datetime(self, hour, minute, second, zone): 1124 return DateTime(self.as_tuple() + (hour, minute, second, zone)) 1125 1126 def as_date(self): 1127 return self 1128 1129 def as_month(self): 1130 return Month(self.data[:2]) 1131 1132 def day(self): 1133 return self.data[2] 1134 1135 def day_update(self, n=1): 1136 1137 "Return the month updated by 'n' months." 1138 1139 delta = datetime.timedelta(n) 1140 dt = datetime.date(*self.as_tuple()[:3]) 1141 dt_new = dt + delta 1142 return Date((dt_new.year, dt_new.month, dt_new.day)) 1143 1144 def next_day(self): 1145 1146 "Return the date following this one." 1147 1148 year, month, day = self.as_tuple()[:3] 1149 _wd, end_day = calendar.monthrange(year, month) 1150 if day == end_day: 1151 if month == 12: 1152 return Date((year + 1, 1, 1)) 1153 else: 1154 return Date((year, month + 1, 1)) 1155 else: 1156 return Date((year, month, day + 1)) 1157 1158 def previous_day(self): 1159 1160 "Return the date preceding this one." 1161 1162 year, month, day = self.as_tuple()[:3] 1163 if day == 1: 1164 if month == 1: 1165 return Date((year - 1, 12, 31)) 1166 else: 1167 _wd, end_day = calendar.monthrange(year, month - 1) 1168 return Date((year, month - 1, end_day)) 1169 else: 1170 return Date((year, month, day - 1)) 1171 1172 def days_until(self, end): 1173 1174 "Return the collection of days from this date until 'end'." 1175 1176 return self.until(self.as_date(), end.as_date(), Date.next_day, Date.previous_day) 1177 1178 class DateTime(Date): 1179 1180 "A simple date plus time representation." 1181 1182 def constrain(self): 1183 Date.constrain(self) 1184 1185 hour, minute, second = self.as_tuple()[3:6] 1186 1187 if self.has_time(): 1188 hour = max(min(hour, 23), 0) 1189 minute = max(min(minute, 59), 0) 1190 1191 if second is not None: 1192 second = max(min(second, 60), 0) # support leap seconds 1193 1194 self.data[3:6] = hour, minute, second 1195 1196 def __str__(self): 1197 if self.has_time(): 1198 data = self.as_tuple() 1199 time_str = " %02d:%02d" % data[3:5] 1200 if data[5] is not None: 1201 time_str += ":%02d" % data[5] 1202 if data[6] is not None: 1203 time_str += " %s" % data[6] 1204 else: 1205 time_str = "" 1206 1207 return Date.__str__(self) + time_str 1208 1209 def as_datetime(self): 1210 return self 1211 1212 def as_date(self): 1213 return Date(self.data[:3]) 1214 1215 def __cmp__(self, other): 1216 if isinstance(other, DateTime): 1217 self_utc = self.to_utc() 1218 other_utc = other.to_utc() 1219 if self_utc is not None and other_utc is not None: 1220 return cmp(self_utc.as_tuple(), other_utc.as_tuple()) 1221 return Month.__cmp__(self, other) 1222 1223 def has_time(self): 1224 return self.data[3] is not None and self.data[4] is not None 1225 1226 def seconds(self): 1227 return self.data[5] 1228 1229 def time_zone(self): 1230 return self.data[6] 1231 1232 def set_time_zone(self, value): 1233 self.data[6] = value 1234 1235 def padded(self): 1236 1237 "Return a datetime with missing fields defined as being zero." 1238 1239 data = map(lambda x: x or 0, self.data[:6]) + self.data[6:] 1240 return DateTime(data) 1241 1242 def to_utc(self): 1243 1244 """ 1245 Return this object converted to UTC, or None if such a conversion is not 1246 defined. 1247 """ 1248 1249 offset = self.utc_offset() 1250 if offset: 1251 hours, minutes = offset 1252 1253 # Invert the offset to get the correction. 1254 1255 hours, minutes = -hours, -minutes 1256 1257 # Get the components. 1258 1259 hour, minute, second, zone = self.as_tuple()[3:] 1260 date = self.as_date() 1261 1262 # Add the minutes and hours. 1263 1264 minute += minutes 1265 if minute < 0 or minute > 59: 1266 hour += minute / 60 1267 minute = minute % 60 1268 1269 # NOTE: This makes various assumptions and probably would not work 1270 # NOTE: for general arithmetic. 1271 1272 hour += hours 1273 if hour < 0: 1274 date = date.previous_day() 1275 hour += 24 1276 elif hour > 23: 1277 date = date.next_day() 1278 hour -= 24 1279 1280 return date.as_datetime(hour, minute, second, "UTC") 1281 1282 # Cannot convert. 1283 1284 else: 1285 return None 1286 1287 def utc_offset(self): 1288 1289 "Return the UTC offset in hours and minutes." 1290 1291 zone = self.time_zone() 1292 if not zone: 1293 return None 1294 1295 # Support explicit UTC zones. 1296 1297 if zone == "UTC": 1298 return 0, 0 1299 1300 # Attempt to return a UTC offset where an explicit offset has been set. 1301 1302 match = timezone_offset_regexp.match(zone) 1303 if match: 1304 if match.group("sign") == "-": 1305 sign = -1 1306 else: 1307 sign = 1 1308 1309 hours = int(match.group("hours")) * sign 1310 minutes = int(match.group("minutes") or 0) * sign 1311 return hours, minutes 1312 1313 # Attempt to handle Olson time zone identifiers. 1314 1315 dt = self.as_olson_datetime() 1316 if dt: 1317 seconds = dt.utcoffset().seconds 1318 hours = seconds / 3600 1319 minutes = (seconds % 3600) / 60 1320 return hours, minutes 1321 1322 # Otherwise return None. 1323 1324 return None 1325 1326 def olson_identifier(self): 1327 1328 "Return the Olson identifier from any zone information." 1329 1330 zone = self.time_zone() 1331 if not zone: 1332 return None 1333 1334 # Attempt to match an identifier. 1335 1336 match = timezone_olson_regexp.match(zone) 1337 if match: 1338 return match.group("olson") 1339 else: 1340 return None 1341 1342 def _as_olson_datetime(self, hours=None): 1343 1344 """ 1345 Return a Python datetime object for this datetime interpreted using any 1346 Olson time zone identifier and the given 'hours' offset, raising one of 1347 the pytz exceptions in case of ambiguity. 1348 """ 1349 1350 olson = self.olson_identifier() 1351 if olson and pytz: 1352 tz = pytz.timezone(olson) 1353 data = self.padded().as_tuple()[:6] 1354 dt = datetime.datetime(*data) 1355 1356 # With an hours offset, find a time probably in a previously 1357 # applicable time zone. 1358 1359 if hours is not None: 1360 td = datetime.timedelta(0, hours * 3600) 1361 dt += td 1362 1363 ldt = tz.localize(dt, None) 1364 1365 # With an hours offset, adjust the time to define it within the 1366 # previously applicable time zone but at the presumably intended 1367 # position. 1368 1369 if hours is not None: 1370 ldt -= td 1371 1372 return ldt 1373 else: 1374 return None 1375 1376 def as_olson_datetime(self): 1377 1378 """ 1379 Return a Python datetime object for this datetime interpreted using any 1380 Olson time zone identifier, choosing the time from the zone before the 1381 period of ambiguity. 1382 """ 1383 1384 try: 1385 return self._as_olson_datetime() 1386 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1387 1388 # Try again, using an earlier local time and then stepping forward 1389 # in the chosen zone. 1390 # NOTE: Four hours earlier seems reasonable. 1391 1392 return self._as_olson_datetime(-4) 1393 1394 def ambiguous(self): 1395 1396 "Return whether the time is local and ambiguous." 1397 1398 try: 1399 self._as_olson_datetime() 1400 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1401 return 1 1402 1403 return 0 1404 1405 class Timespan: 1406 1407 """ 1408 A period of time which can be compared against others to check for overlaps. 1409 """ 1410 1411 def __init__(self, start, end): 1412 self.start = start 1413 self.end = end 1414 1415 def __repr__(self): 1416 return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end) 1417 1418 def __hash__(self): 1419 return hash((self.start, self.end)) 1420 1421 def as_times(self): 1422 return self.start, self.end 1423 1424 def is_before(self, a, b): 1425 if isinstance(a, DateTime) and isinstance(b, DateTime): 1426 return a <= b 1427 else: 1428 return a < b 1429 1430 def is_after_or_during(self, a, b): 1431 if isinstance(a, DateTime) and isinstance(b, DateTime): 1432 return a > b 1433 else: 1434 return a >= b 1435 1436 def __contains__(self, other): 1437 return self == other 1438 1439 def __cmp__(self, other): 1440 1441 """ 1442 Return whether this timespan occupies the same period of time as the 1443 'other'. 1444 """ 1445 1446 if isinstance(other, Timespan): 1447 if self.end is not None and other.start is not None and self.is_before(self.end, other.start): 1448 return -1 1449 elif self.start is not None and other.end is not None and self.is_before(other.end, self.start): 1450 return 1 1451 else: 1452 return 0 1453 1454 # Points in time are not considered to represent an upper bound on a 1455 # non-inclusive timespan. 1456 1457 else: 1458 if self.end is not None and self.is_before(self.end, other): 1459 return -1 1460 elif self.start is not None and self.start > other: 1461 return 1 1462 else: 1463 return 0 1464 1465 class TimespanCollection: 1466 1467 "A collection of timespans with a particular resolution." 1468 1469 def __init__(self, resolution, values=None): 1470 1471 # Timespans need to be given converted start and end dates/times. 1472 1473 if resolution == "date": 1474 self.convert_time = lambda x: x.as_date() 1475 else: 1476 self.convert_time = lambda x: x 1477 1478 self.values = values or [] 1479 1480 def convert(self, value): 1481 if isinstance(value, Event): 1482 value = value.as_timespan() 1483 1484 if isinstance(value, Timespan): 1485 start, end = map(self.convert_time, value.as_times()) 1486 return Timespan(start, end) 1487 else: 1488 return self.convert_time(value) 1489 1490 def __iter__(self): 1491 return iter(self.values) 1492 1493 def __len__(self): 1494 return len(self.values) 1495 1496 def __getitem__(self, i): 1497 return self.values[i] 1498 1499 def __setitem__(self, i, value): 1500 self.values[i] = value 1501 1502 def __contains__(self, value): 1503 test_value = self.convert(value) 1504 return test_value in self.values 1505 1506 def append(self, value): 1507 self.values.append(value) 1508 1509 def insert(self, i, value): 1510 self.values.insert(i, value) 1511 1512 def pop(self): 1513 return self.values.pop() 1514 1515 def insert_in_order(self, value): 1516 bisect.insort_left(self, value) 1517 1518 def getCountry(s): 1519 1520 "Find a country code in the given string 's'." 1521 1522 match = country_code_regexp.search(s) 1523 1524 if match: 1525 return match.group("code") 1526 else: 1527 return None 1528 1529 def getDate(s): 1530 1531 "Parse the string 's', extracting and returning a date object." 1532 1533 dt = getDateTime(s) 1534 if dt is not None: 1535 return dt.as_date() 1536 else: 1537 return None 1538 1539 def getDateTime(s): 1540 1541 "Parse the string 's', extracting and returning a datetime object." 1542 1543 m = datetime_regexp.search(s) 1544 if m: 1545 groups = list(m.groups()) 1546 1547 # Convert date and time data to integer or None. 1548 1549 return DateTime(map(int_or_none, groups[:6]) + [m.group("zone")]) 1550 else: 1551 return None 1552 1553 def getDateStrings(s): 1554 1555 "Parse the string 's', extracting and returning all date strings." 1556 1557 start = 0 1558 m = date_regexp.search(s, start) 1559 l = [] 1560 while m: 1561 l.append("-".join(m.groups())) 1562 m = date_regexp.search(s, m.end()) 1563 return l 1564 1565 def getMonth(s): 1566 1567 "Parse the string 's', extracting and returning a month object." 1568 1569 m = month_regexp.search(s) 1570 if m: 1571 return Month(map(int, m.groups())) 1572 else: 1573 return None 1574 1575 def getCurrentDate(): 1576 1577 "Return the current date as a (year, month, day) tuple." 1578 1579 today = datetime.date.today() 1580 return Date((today.year, today.month, today.day)) 1581 1582 def getCurrentMonth(): 1583 1584 "Return the current month as a (year, month) tuple." 1585 1586 today = datetime.date.today() 1587 return Month((today.year, today.month)) 1588 1589 def getCurrentYear(): 1590 1591 "Return the current year." 1592 1593 today = datetime.date.today() 1594 return today.year 1595 1596 # User interface functions. 1597 1598 def getParameter(request, name, default=None): 1599 1600 """ 1601 Using the given 'request', return the value of the parameter with the given 1602 'name', returning the optional 'default' (or None) if no value was supplied 1603 in the 'request'. 1604 """ 1605 1606 return get_form(request).get(name, [default])[0] 1607 1608 def getQualifiedParameter(request, calendar_name, argname, default=None): 1609 1610 """ 1611 Using the given 'request', 'calendar_name' and 'argname', retrieve the 1612 value of the qualified parameter, returning the optional 'default' (or None) 1613 if no value was supplied in the 'request'. 1614 """ 1615 1616 argname = getQualifiedParameterName(calendar_name, argname) 1617 return getParameter(request, argname, default) 1618 1619 def getQualifiedParameterName(calendar_name, argname): 1620 1621 """ 1622 Return the qualified parameter name using the given 'calendar_name' and 1623 'argname'. 1624 """ 1625 1626 if calendar_name is None: 1627 return argname 1628 else: 1629 return "%s-%s" % (calendar_name, argname) 1630 1631 def getParameterDate(arg): 1632 1633 "Interpret 'arg', recognising keywords and simple arithmetic operations." 1634 1635 n = None 1636 1637 if arg.startswith("current"): 1638 date = getCurrentDate() 1639 if len(arg) > 8: 1640 n = int(arg[7:]) 1641 1642 elif arg.startswith("yearstart"): 1643 date = Date((getCurrentYear(), 1, 1)) 1644 if len(arg) > 10: 1645 n = int(arg[9:]) 1646 1647 elif arg.startswith("yearend"): 1648 date = Date((getCurrentYear(), 12, 31)) 1649 if len(arg) > 8: 1650 n = int(arg[7:]) 1651 1652 else: 1653 date = getDate(arg) 1654 1655 if n is not None: 1656 date = date.day_update(n) 1657 1658 return date 1659 1660 def getParameterMonth(arg): 1661 1662 "Interpret 'arg', recognising keywords and simple arithmetic operations." 1663 1664 n = None 1665 1666 if arg.startswith("current"): 1667 date = getCurrentMonth() 1668 if len(arg) > 8: 1669 n = int(arg[7:]) 1670 1671 elif arg.startswith("yearstart"): 1672 date = Month((getCurrentYear(), 1)) 1673 if len(arg) > 10: 1674 n = int(arg[9:]) 1675 1676 elif arg.startswith("yearend"): 1677 date = Month((getCurrentYear(), 12)) 1678 if len(arg) > 8: 1679 n = int(arg[7:]) 1680 1681 else: 1682 date = getMonth(arg) 1683 1684 if n is not None: 1685 date = date.month_update(n) 1686 1687 return date 1688 1689 def getFormDate(request, calendar_name, argname): 1690 1691 """ 1692 Return the date from the 'request' for the calendar with the given 1693 'calendar_name' using the parameter having the given 'argname'. 1694 """ 1695 1696 arg = getQualifiedParameter(request, calendar_name, argname) 1697 if arg is not None: 1698 return getParameterDate(arg) 1699 else: 1700 return None 1701 1702 def getFormMonth(request, calendar_name, argname): 1703 1704 """ 1705 Return the month from the 'request' for the calendar with the given 1706 'calendar_name' using the parameter having the given 'argname'. 1707 """ 1708 1709 arg = getQualifiedParameter(request, calendar_name, argname) 1710 if arg is not None: 1711 return getParameterMonth(arg) 1712 else: 1713 return None 1714 1715 def getFormDateTriple(request, yeararg, montharg, dayarg): 1716 1717 """ 1718 Return the date from the 'request' for the calendar with the given 1719 'calendar_name' using the parameters having the given 'yeararg', 'montharg' 1720 and 'dayarg' names. 1721 """ 1722 1723 year = getParameter(request, yeararg) 1724 month = getParameter(request, montharg) 1725 day = getParameter(request, dayarg) 1726 if year and month and day: 1727 return Date((int(year), int(month), int(day))) 1728 else: 1729 return None 1730 1731 def getFormMonthPair(request, yeararg, montharg): 1732 1733 """ 1734 Return the month from the 'request' for the calendar with the given 1735 'calendar_name' using the parameters having the given 'yeararg' and 1736 'montharg' names. 1737 """ 1738 1739 year = getParameter(request, yeararg) 1740 month = getParameter(request, montharg) 1741 if year and month: 1742 return Month((int(year), int(month))) 1743 else: 1744 return None 1745 1746 def getFullDateLabel(request, date): 1747 1748 """ 1749 Return the full month plus year label using the given 'request' and 1750 'year_month'. 1751 """ 1752 1753 _ = request.getText 1754 year, month, day = date.as_tuple()[:3] 1755 start_weekday, number_of_days = date.month_properties() 1756 weekday = (start_weekday + day - 1) % 7 1757 day_label = _(getDayLabel(weekday)) 1758 month_label = _(getMonthLabel(month)) 1759 return "%s %s %s %s" % (day_label, day, month_label, year) 1760 1761 def getFullMonthLabel(request, year_month): 1762 1763 """ 1764 Return the full month plus year label using the given 'request' and 1765 'year_month'. 1766 """ 1767 1768 _ = request.getText 1769 year, month = year_month.as_tuple()[:2] 1770 month_label = _(getMonthLabel(month)) 1771 return "%s %s" % (month_label, year) 1772 1773 # Page-related functions. 1774 1775 def getPrettyPageName(page): 1776 1777 "Return a nicely formatted title/name for the given 'page'." 1778 1779 title = page.split_title(force=1) 1780 return getPrettyTitle(title) 1781 1782 def linkToPage(request, page, text, query_string=None): 1783 1784 """ 1785 Using 'request', return a link to 'page' with the given link 'text' and 1786 optional 'query_string'. 1787 """ 1788 1789 text = wikiutil.escape(text) 1790 return page.link_to_raw(request, text, query_string) 1791 1792 def getFullPageName(parent, title): 1793 1794 """ 1795 Return a full page name from the given 'parent' page (can be empty or None) 1796 and 'title' (a simple page name). 1797 """ 1798 1799 if parent: 1800 return "%s/%s" % (parent.rstrip("/"), title) 1801 else: 1802 return title 1803 1804 def fillEventPageFromTemplate(template_page, new_page, event_details, category_pagenames): 1805 1806 """ 1807 Using the given 'template_page', complete the 'new_page' by copying the 1808 template and adding the given 'event_details' (a dictionary of event 1809 fields), setting also the 'category_pagenames' to define category 1810 membership. 1811 """ 1812 1813 event_page = EventPage(template_page) 1814 new_event_page = EventPage(new_page) 1815 new_event_page.copyPage(event_page) 1816 1817 if new_event_page.getFormat() == "wiki": 1818 new_event = Event(new_event_page, event_details) 1819 new_event_page.setEvents([new_event]) 1820 new_event_page.setCategoryMembership(category_pagenames) 1821 new_event_page.saveChanges() 1822 1823 # vim: tabstop=4 expandtab shiftwidth=4