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