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 Period: 1106 1107 "A simple period of time." 1108 1109 def __init__(self, data): 1110 self.data = data 1111 1112 def count(self): 1113 return self.data[0] * 12 + self.data[1] 1114 1115 class Convertible: 1116 1117 "Support for converting temporal objects." 1118 1119 def _get_converter(self, resolution): 1120 if resolution == "month": 1121 return lambda x: x and x.as_month() 1122 elif resolution == "date": 1123 return lambda x: x and x.as_date() 1124 elif resolution == "datetime": 1125 return lambda x: x and x.as_datetime_or_date() 1126 else: 1127 return lambda x: x 1128 1129 class Temporal(Convertible): 1130 1131 "A simple temporal representation, common to dates and times." 1132 1133 def __init__(self, data): 1134 self.data = list(data) 1135 1136 def __repr__(self): 1137 return "%s(%r)" % (self.__class__.__name__, self.data) 1138 1139 def __hash__(self): 1140 return hash(self.as_tuple()) 1141 1142 def as_tuple(self): 1143 return tuple(self.data) 1144 1145 def convert(self, resolution): 1146 return self._get_converter(resolution)(self) 1147 1148 def __cmp__(self, other): 1149 1150 """ 1151 The result of comparing this instance with 'other' is derived from a 1152 comparison of the instances' date(time) data at the highest common 1153 resolution, meaning that if a date is compared to a datetime, the 1154 datetime will be considered as a date. Thus, a date and a datetime 1155 referring to the same date will be considered equal. 1156 """ 1157 1158 if not isinstance(other, Temporal): 1159 return NotImplemented 1160 else: 1161 data = self.as_tuple() 1162 other_data = other.as_tuple() 1163 length = min(len(data), len(other_data)) 1164 return cmp(data[:length], other_data[:length]) 1165 1166 def _until(self, start, end, nextfn, prevfn): 1167 1168 """ 1169 Return a collection of units of time by starting from the given 'start' 1170 and stepping across intervening units until 'end' is reached, using the 1171 given 'nextfn' and 'prevfn' to step from one unit to the next. 1172 """ 1173 1174 current = start 1175 units = [current] 1176 if current < end: 1177 while current < end: 1178 current = nextfn(current) 1179 units.append(current) 1180 elif current > end: 1181 while current > end: 1182 current = prevfn(current) 1183 units.append(current) 1184 return units 1185 1186 def ambiguous(self): 1187 1188 "Only times can be ambiguous." 1189 1190 return 0 1191 1192 class Month(Temporal): 1193 1194 "A simple year-month representation." 1195 1196 def __str__(self): 1197 return "%04d-%02d" % self.as_tuple()[:2] 1198 1199 def as_datetime(self, day, hour, minute, second, zone): 1200 return DateTime(self.as_tuple() + (day, hour, minute, second, zone)) 1201 1202 def as_date(self, day): 1203 return Date(self.as_tuple() + (day,)) 1204 1205 def as_month(self): 1206 return self 1207 1208 def year(self): 1209 return self.data[0] 1210 1211 def month(self): 1212 return self.data[1] 1213 1214 def month_properties(self): 1215 1216 """ 1217 Return the weekday of the 1st of the month, along with the number of 1218 days, as a tuple. 1219 """ 1220 1221 year, month = self.as_tuple()[:2] 1222 return calendar.monthrange(year, month) 1223 1224 def month_update(self, n=1): 1225 1226 "Return the month updated by 'n' months." 1227 1228 year, month = self.as_tuple()[:2] 1229 return Month((year + (month - 1 + n) / 12, (month - 1 + n) % 12 + 1)) 1230 1231 update = month_update 1232 1233 def next_month(self): 1234 1235 "Return the month following this one." 1236 1237 return self.month_update(1) 1238 1239 next = next_month 1240 1241 def previous_month(self): 1242 1243 "Return the month preceding this one." 1244 1245 return self.month_update(-1) 1246 1247 previous = previous_month 1248 1249 def __sub__(self, start): 1250 1251 """ 1252 Return the difference in years and months between this month and the 1253 'start' month as a period. 1254 """ 1255 1256 return Period([(x - y) for x, y in zip(self.data, start.data)]) 1257 1258 def months_until(self, end): 1259 1260 "Return the collection of months from this month until 'end'." 1261 1262 return self._until(self.as_month(), end.as_month(), Month.next_month, Month.previous_month) 1263 1264 until = months_until 1265 1266 class Date(Month): 1267 1268 "A simple year-month-day representation." 1269 1270 def constrain(self): 1271 year, month, day = self.as_tuple()[:3] 1272 1273 month = max(min(month, 12), 1) 1274 wd, last_day = calendar.monthrange(year, month) 1275 day = max(min(day, last_day), 1) 1276 1277 self.data[1:3] = month, day 1278 1279 def __str__(self): 1280 return "%04d-%02d-%02d" % self.as_tuple()[:3] 1281 1282 def as_datetime(self, hour, minute, second, zone): 1283 return DateTime(self.as_tuple() + (hour, minute, second, zone)) 1284 1285 def as_date(self): 1286 return self 1287 1288 def as_datetime_or_date(self): 1289 return self 1290 1291 def as_month(self): 1292 return Month(self.data[:2]) 1293 1294 def day(self): 1295 return self.data[2] 1296 1297 def day_update(self, n=1): 1298 1299 "Return the month updated by 'n' days." 1300 1301 delta = datetime.timedelta(n) 1302 dt = datetime.date(*self.as_tuple()[:3]) 1303 dt_new = dt + delta 1304 return Date((dt_new.year, dt_new.month, dt_new.day)) 1305 1306 update = day_update 1307 1308 def next_day(self): 1309 1310 "Return the date following this one." 1311 1312 year, month, day = self.as_tuple()[:3] 1313 _wd, end_day = calendar.monthrange(year, month) 1314 if day == end_day: 1315 if month == 12: 1316 return Date((year + 1, 1, 1)) 1317 else: 1318 return Date((year, month + 1, 1)) 1319 else: 1320 return Date((year, month, day + 1)) 1321 1322 next = next_day 1323 1324 def previous_day(self): 1325 1326 "Return the date preceding this one." 1327 1328 year, month, day = self.as_tuple()[:3] 1329 if day == 1: 1330 if month == 1: 1331 return Date((year - 1, 12, 31)) 1332 else: 1333 _wd, end_day = calendar.monthrange(year, month - 1) 1334 return Date((year, month - 1, end_day)) 1335 else: 1336 return Date((year, month, day - 1)) 1337 1338 previous = previous_day 1339 1340 def days_until(self, end): 1341 1342 "Return the collection of days from this date until 'end'." 1343 1344 return self._until(self.as_date(), end.as_date(), Date.next_day, Date.previous_day) 1345 1346 until = days_until 1347 1348 class DateTime(Date): 1349 1350 "A simple date plus time representation." 1351 1352 def constrain(self): 1353 Date.constrain(self) 1354 1355 hour, minute, second = self.as_tuple()[3:6] 1356 1357 if self.has_time(): 1358 hour = max(min(hour, 23), 0) 1359 minute = max(min(minute, 59), 0) 1360 1361 if second is not None: 1362 second = max(min(second, 60), 0) # support leap seconds 1363 1364 self.data[3:6] = hour, minute, second 1365 1366 def __str__(self): 1367 return Date.__str__(self) + self.time_string() 1368 1369 def time_string(self): 1370 if self.has_time(): 1371 data = self.as_tuple() 1372 time_str = " %02d:%02d" % data[3:5] 1373 if data[5] is not None: 1374 time_str += ":%02d" % data[5] 1375 if data[6] is not None: 1376 time_str += " %s" % data[6] 1377 return time_str 1378 else: 1379 return "" 1380 1381 def as_datetime(self): 1382 return self 1383 1384 def as_date(self): 1385 return Date(self.data[:3]) 1386 1387 def as_datetime_or_date(self): 1388 1389 """ 1390 Return a date for this datetime if fields are missing. Otherwise, return 1391 this datetime itself. 1392 """ 1393 1394 if not self.has_time(): 1395 return self.as_date() 1396 else: 1397 return self 1398 1399 def __cmp__(self, other): 1400 1401 """ 1402 The result of comparing this instance with 'other' is, if both instances 1403 are datetime instances, derived from a comparison of the datetimes 1404 converted to UTC. If one or both datetimes cannot be converted to UTC, 1405 the datetimes are compared using the basic temporal comparison which 1406 compares their raw time data. 1407 """ 1408 1409 this = self 1410 1411 if this.has_time(): 1412 if isinstance(other, DateTime): 1413 if other.has_time(): 1414 this_utc = this.to_utc() 1415 other_utc = other.to_utc() 1416 if this_utc is not None and other_utc is not None: 1417 return cmp(this_utc.as_tuple(), other_utc.as_tuple()) 1418 else: 1419 other = other.padded() 1420 else: 1421 this = this.padded() 1422 1423 return Date.__cmp__(this, other) 1424 1425 def has_time(self): 1426 1427 """ 1428 Return whether this object has any time information. Objects without 1429 time information can refer to the very start of a day. 1430 """ 1431 1432 return self.data[3] is not None and self.data[4] is not None 1433 1434 def time(self): 1435 return self.data[3:] 1436 1437 def seconds(self): 1438 return self.data[5] 1439 1440 def time_zone(self): 1441 return self.data[6] 1442 1443 def set_time_zone(self, value): 1444 self.data[6] = value 1445 1446 def padded(self, empty_value=0): 1447 1448 """ 1449 Return a datetime with missing fields defined as being the given 1450 'empty_value' or 0 if not specified. 1451 """ 1452 1453 data = [] 1454 for x in self.data[:6]: 1455 if x is None: 1456 data.append(empty_value) 1457 else: 1458 data.append(x) 1459 1460 data += self.data[6:] 1461 return DateTime(data) 1462 1463 def to_utc(self): 1464 1465 """ 1466 Return this object converted to UTC, or None if such a conversion is not 1467 defined. 1468 """ 1469 1470 if not self.has_time(): 1471 return None 1472 1473 offset = self.utc_offset() 1474 if offset: 1475 hours, minutes = offset 1476 1477 # Invert the offset to get the correction. 1478 1479 hours, minutes = -hours, -minutes 1480 1481 # Get the components. 1482 1483 hour, minute, second, zone = self.time() 1484 date = self.as_date() 1485 1486 # Add the minutes and hours. 1487 1488 minute += minutes 1489 if minute < 0 or minute > 59: 1490 hour += minute / 60 1491 minute = minute % 60 1492 1493 # NOTE: This makes various assumptions and probably would not work 1494 # NOTE: for general arithmetic. 1495 1496 hour += hours 1497 if hour < 0: 1498 date = date.previous_day() 1499 hour += 24 1500 elif hour > 23: 1501 date = date.next_day() 1502 hour -= 24 1503 1504 return date.as_datetime(hour, minute, second, "UTC") 1505 1506 # Cannot convert. 1507 1508 else: 1509 return None 1510 1511 def utc_offset(self): 1512 1513 "Return the UTC offset in hours and minutes." 1514 1515 zone = self.time_zone() 1516 if not zone: 1517 return None 1518 1519 # Support explicit UTC zones. 1520 1521 if zone == "UTC": 1522 return 0, 0 1523 1524 # Attempt to return a UTC offset where an explicit offset has been set. 1525 1526 match = timezone_offset_regexp.match(zone) 1527 if match: 1528 if match.group("sign") == "-": 1529 sign = -1 1530 else: 1531 sign = 1 1532 1533 hours = int(match.group("hours")) * sign 1534 minutes = int(match.group("minutes") or 0) * sign 1535 return hours, minutes 1536 1537 # Attempt to handle Olson time zone identifiers. 1538 1539 dt = self.as_olson_datetime() 1540 if dt: 1541 seconds = dt.utcoffset().seconds 1542 hours = seconds / 3600 1543 minutes = (seconds % 3600) / 60 1544 return hours, minutes 1545 1546 # Otherwise return None. 1547 1548 return None 1549 1550 def olson_identifier(self): 1551 1552 "Return the Olson identifier from any zone information." 1553 1554 zone = self.time_zone() 1555 if not zone: 1556 return None 1557 1558 # Attempt to match an identifier. 1559 1560 match = timezone_olson_regexp.match(zone) 1561 if match: 1562 return match.group("olson") 1563 else: 1564 return None 1565 1566 def _as_olson_datetime(self, hours=None): 1567 1568 """ 1569 Return a Python datetime object for this datetime interpreted using any 1570 Olson time zone identifier and the given 'hours' offset, raising one of 1571 the pytz exceptions in case of ambiguity. 1572 """ 1573 1574 olson = self.olson_identifier() 1575 if olson and pytz: 1576 tz = pytz.timezone(olson) 1577 data = self.padded().as_tuple()[:6] 1578 dt = datetime.datetime(*data) 1579 1580 # With an hours offset, find a time probably in a previously 1581 # applicable time zone. 1582 1583 if hours is not None: 1584 td = datetime.timedelta(0, hours * 3600) 1585 dt += td 1586 1587 ldt = tz.localize(dt, None) 1588 1589 # With an hours offset, adjust the time to define it within the 1590 # previously applicable time zone but at the presumably intended 1591 # position. 1592 1593 if hours is not None: 1594 ldt -= td 1595 1596 return ldt 1597 else: 1598 return None 1599 1600 def as_olson_datetime(self): 1601 1602 """ 1603 Return a Python datetime object for this datetime interpreted using any 1604 Olson time zone identifier, choosing the time from the zone before the 1605 period of ambiguity. 1606 """ 1607 1608 try: 1609 return self._as_olson_datetime() 1610 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1611 1612 # Try again, using an earlier local time and then stepping forward 1613 # in the chosen zone. 1614 # NOTE: Four hours earlier seems reasonable. 1615 1616 return self._as_olson_datetime(-4) 1617 1618 def ambiguous(self): 1619 1620 "Return whether the time is local and ambiguous." 1621 1622 try: 1623 self._as_olson_datetime() 1624 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 1625 return 1 1626 1627 return 0 1628 1629 class Timespan(ActsAsTimespan, Convertible): 1630 1631 """ 1632 A period of time which can be compared against others to check for overlaps. 1633 """ 1634 1635 def __init__(self, start, end): 1636 self.start = start 1637 self.end = end 1638 1639 # NOTE: Should perhaps catch ambiguous time problems elsewhere. 1640 1641 if self.ambiguous() and self.start is not None and self.end is not None and start > end: 1642 self.start, self.end = end, start 1643 1644 def __repr__(self): 1645 return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end) 1646 1647 def __hash__(self): 1648 return hash((self.start, self.end)) 1649 1650 def as_timespan(self): 1651 return self 1652 1653 def as_limits(self): 1654 return self.start, self.end 1655 1656 def ambiguous(self): 1657 return self.start is not None and self.start.ambiguous() or self.end is not None and self.end.ambiguous() 1658 1659 def convert(self, resolution): 1660 return Timespan(*map(self._get_converter(resolution), self.as_limits())) 1661 1662 def is_before(self, a, b): 1663 1664 """ 1665 Return whether 'a' is before 'b'. Since the end datetime of one period 1666 may be the same as the start datetime of another period, and yet the 1667 first period is intended to be concluded by the end datetime and not 1668 overlap with the other period, a different test is employed for datetime 1669 comparisons. 1670 """ 1671 1672 # Datetimes without times can be equal to dates and be considered as 1673 # occurring before those dates. Generally, datetimes should not be 1674 # produced without time information as getDateTime converts such 1675 # datetimes to dates. 1676 1677 if isinstance(a, DateTime) and (isinstance(b, DateTime) or not a.has_time()): 1678 return a <= b 1679 else: 1680 return a < b 1681 1682 def __contains__(self, other): 1683 1684 """ 1685 This instance is considered to contain 'other' if one is not before or 1686 after the other. If this instance overlaps or coincides with 'other', 1687 then 'other' is regarded as belonging to this instance's time period. 1688 """ 1689 1690 return self == other 1691 1692 def __cmp__(self, other): 1693 1694 """ 1695 Return whether this timespan occupies the same period of time as the 1696 'other'. Timespans are considered less than others if their end points 1697 precede the other's start point, and are considered greater than others 1698 if their start points follow the other's end point. 1699 """ 1700 1701 if isinstance(other, ActsAsTimespan): 1702 other = other.as_timespan() 1703 1704 if self.end is not None and other.start is not None and self.is_before(self.end, other.start): 1705 return -1 1706 elif self.start is not None and other.end is not None and self.is_before(other.end, self.start): 1707 return 1 1708 else: 1709 return 0 1710 1711 else: 1712 if self.end is not None and self.is_before(self.end, other): 1713 return -1 1714 elif self.start is not None and self.is_before(other, self.start): 1715 return 1 1716 else: 1717 return 0 1718 1719 class TimespanCollection: 1720 1721 """ 1722 A class providing a list-like interface supporting membership tests at a 1723 particular resolution in order to maintain a collection of non-overlapping 1724 timespans. 1725 """ 1726 1727 def __init__(self, resolution, values=None): 1728 self.resolution = resolution 1729 self.values = values or [] 1730 1731 def as_timespan(self): 1732 return Timespan(*self.as_limits()) 1733 1734 def as_limits(self): 1735 1736 "Return the earliest and latest points in time for this collection." 1737 1738 if not self.values: 1739 return None, None 1740 else: 1741 first, last = self.values[0], self.values[-1] 1742 if isinstance(first, ActsAsTimespan): 1743 first = first.as_timespan().start 1744 if isinstance(last, ActsAsTimespan): 1745 last = last.as_timespan().end 1746 return first, last 1747 1748 def convert(self, value): 1749 if isinstance(value, ActsAsTimespan): 1750 ts = value.as_timespan() 1751 return ts and ts.convert(self.resolution) 1752 else: 1753 return value.convert(self.resolution) 1754 1755 def __iter__(self): 1756 return iter(self.values) 1757 1758 def __len__(self): 1759 return len(self.values) 1760 1761 def __getitem__(self, i): 1762 return self.values[i] 1763 1764 def __setitem__(self, i, value): 1765 self.values[i] = value 1766 1767 def __contains__(self, value): 1768 test_value = self.convert(value) 1769 return test_value in self.values 1770 1771 def append(self, value): 1772 self.values.append(value) 1773 1774 def insert(self, i, value): 1775 self.values.insert(i, value) 1776 1777 def pop(self): 1778 return self.values.pop() 1779 1780 def insert_in_order(self, value): 1781 bisect.insort_left(self, value) 1782 1783 def getCountry(s): 1784 1785 "Find a country code in the given string 's'." 1786 1787 match = country_code_regexp.search(s) 1788 1789 if match: 1790 return match.group("code") 1791 else: 1792 return None 1793 1794 def getDate(s): 1795 1796 "Parse the string 's', extracting and returning a date object." 1797 1798 dt = getDateTime(s) 1799 if dt is not None: 1800 return dt.as_date() 1801 else: 1802 return None 1803 1804 def getDateTime(s): 1805 1806 """ 1807 Parse the string 's', extracting and returning a datetime object where time 1808 information has been given or a date object where time information is 1809 absent. 1810 """ 1811 1812 m = datetime_regexp.search(s) 1813 if m: 1814 groups = list(m.groups()) 1815 1816 # Convert date and time data to integer or None. 1817 1818 return DateTime(map(int_or_none, groups[:6]) + [m.group("zone")]).as_datetime_or_date() 1819 else: 1820 return None 1821 1822 def getDateStrings(s): 1823 1824 "Parse the string 's', extracting and returning all date strings." 1825 1826 start = 0 1827 m = date_regexp.search(s, start) 1828 l = [] 1829 while m: 1830 l.append("-".join(m.groups())) 1831 m = date_regexp.search(s, m.end()) 1832 return l 1833 1834 def getMonth(s): 1835 1836 "Parse the string 's', extracting and returning a month object." 1837 1838 m = month_regexp.search(s) 1839 if m: 1840 return Month(map(int, m.groups())) 1841 else: 1842 return None 1843 1844 def getCurrentDate(): 1845 1846 "Return the current date as a (year, month, day) tuple." 1847 1848 today = datetime.date.today() 1849 return Date((today.year, today.month, today.day)) 1850 1851 def getCurrentMonth(): 1852 1853 "Return the current month as a (year, month) tuple." 1854 1855 today = datetime.date.today() 1856 return Month((today.year, today.month)) 1857 1858 def getCurrentYear(): 1859 1860 "Return the current year." 1861 1862 today = datetime.date.today() 1863 return today.year 1864 1865 # Location-related functions. 1866 1867 class Reference: 1868 1869 "A map reference." 1870 1871 def __init__(self, degrees, minutes=0, seconds=0): 1872 self.degrees = degrees 1873 self.minutes = minutes 1874 self.seconds = seconds 1875 1876 def __repr__(self): 1877 return "Reference(%d, %d, %d)" % (self.degrees, self.minutes, self.seconds) 1878 1879 def __add__(self, other): 1880 if not isinstance(other, Reference): 1881 return NotImplemented 1882 else: 1883 s = sign(self.degrees) 1884 o = sign(other.degrees) 1885 carry, seconds = adc(s * self.seconds, o * other.seconds) 1886 carry, minutes = adc(s * self.minutes, o * other.minutes + carry) 1887 return Reference(self.degrees + other.degrees + carry, minutes, seconds) 1888 1889 def __sub__(self, other): 1890 if not isinstance(other, Reference): 1891 return NotImplemented 1892 else: 1893 return self.__add__(Reference(-other.degrees, other.minutes, other.seconds)) 1894 1895 def _compare(self, op, other): 1896 if not isinstance(other, Reference): 1897 return NotImplemented 1898 else: 1899 return op(self.to_degrees(), other.to_degrees()) 1900 1901 def __eq__(self, other): 1902 return self._compare(operator.eq, other) 1903 1904 def __ne__(self, other): 1905 return self._compare(operator.ne, other) 1906 1907 def __lt__(self, other): 1908 return self._compare(operator.lt, other) 1909 1910 def __le__(self, other): 1911 return self._compare(operator.le, other) 1912 1913 def __gt__(self, other): 1914 return self._compare(operator.gt, other) 1915 1916 def __ge__(self, other): 1917 return self._compare(operator.ge, other) 1918 1919 def to_degrees(self): 1920 return sign(self.degrees) * (abs(self.degrees) + self.minutes / 60.0 + self.seconds / 3600.0) 1921 1922 def to_pixels(self, scale): 1923 return self.to_degrees() * scale 1924 1925 def adc(x, y): 1926 result = x + y 1927 return divmod(result, 60) 1928 1929 def getPositionForReference(latitude, longitude, map_y, map_x, map_x_scale, map_y_scale): 1930 return (longitude - map_x).to_pixels(map_x_scale), (latitude - map_y).to_pixels(map_y_scale) 1931 1932 def getPositionForCentrePoint(position, map_x_scale, map_y_scale): 1933 x, y = position 1934 return x - map_x_scale / 2.0, y - map_y_scale / 2.0 1935 1936 def getMapReference(value): 1937 1938 "Return a map reference by parsing the given 'value'." 1939 1940 return Reference(*map(float, value.split(":"))) 1941 1942 # vim: tabstop=4 expandtab shiftwidth=4 1943 1944 # User interface functions. 1945 1946 def getParameter(request, name, default=None): 1947 1948 """ 1949 Using the given 'request', return the value of the parameter with the given 1950 'name', returning the optional 'default' (or None) if no value was supplied 1951 in the 'request'. 1952 """ 1953 1954 return get_form(request).get(name, [default])[0] 1955 1956 def getQualifiedParameter(request, calendar_name, argname, default=None): 1957 1958 """ 1959 Using the given 'request', 'calendar_name' and 'argname', retrieve the 1960 value of the qualified parameter, returning the optional 'default' (or None) 1961 if no value was supplied in the 'request'. 1962 """ 1963 1964 argname = getQualifiedParameterName(calendar_name, argname) 1965 return getParameter(request, argname, default) 1966 1967 def getQualifiedParameterName(calendar_name, argname): 1968 1969 """ 1970 Return the qualified parameter name using the given 'calendar_name' and 1971 'argname'. 1972 """ 1973 1974 if calendar_name is None: 1975 return argname 1976 else: 1977 return "%s-%s" % (calendar_name, argname) 1978 1979 def getParameterDate(arg): 1980 1981 "Interpret 'arg', recognising keywords and simple arithmetic operations." 1982 1983 n = None 1984 1985 if arg is None: 1986 return None 1987 1988 elif arg.startswith("current"): 1989 date = getCurrentDate() 1990 if len(arg) > 8: 1991 n = int(arg[7:]) 1992 1993 elif arg.startswith("yearstart"): 1994 date = Date((getCurrentYear(), 1, 1)) 1995 if len(arg) > 10: 1996 n = int(arg[9:]) 1997 1998 elif arg.startswith("yearend"): 1999 date = Date((getCurrentYear(), 12, 31)) 2000 if len(arg) > 8: 2001 n = int(arg[7:]) 2002 2003 else: 2004 date = getDate(arg) 2005 2006 if n is not None: 2007 date = date.day_update(n) 2008 2009 return date 2010 2011 def getParameterMonth(arg): 2012 2013 "Interpret 'arg', recognising keywords and simple arithmetic operations." 2014 2015 n = None 2016 2017 if arg is None: 2018 return None 2019 2020 elif arg.startswith("current"): 2021 date = getCurrentMonth() 2022 if len(arg) > 8: 2023 n = int(arg[7:]) 2024 2025 elif arg.startswith("yearstart"): 2026 date = Month((getCurrentYear(), 1)) 2027 if len(arg) > 10: 2028 n = int(arg[9:]) 2029 2030 elif arg.startswith("yearend"): 2031 date = Month((getCurrentYear(), 12)) 2032 if len(arg) > 8: 2033 n = int(arg[7:]) 2034 2035 else: 2036 date = getMonth(arg) 2037 2038 if n is not None: 2039 date = date.month_update(n) 2040 2041 return date 2042 2043 def getFormDate(request, calendar_name, argname): 2044 2045 """ 2046 Return the date from the 'request' for the calendar with the given 2047 'calendar_name' using the parameter having the given 'argname'. 2048 """ 2049 2050 arg = getQualifiedParameter(request, calendar_name, argname) 2051 return getParameterDate(arg) 2052 2053 def getFormMonth(request, calendar_name, argname): 2054 2055 """ 2056 Return the month from the 'request' for the calendar with the given 2057 'calendar_name' using the parameter having the given 'argname'. 2058 """ 2059 2060 arg = getQualifiedParameter(request, calendar_name, argname) 2061 return getParameterMonth(arg) 2062 2063 def getFormDateTriple(request, yeararg, montharg, dayarg): 2064 2065 """ 2066 Return the date from the 'request' for the calendar with the given 2067 'calendar_name' using the parameters having the given 'yeararg', 'montharg' 2068 and 'dayarg' names. 2069 """ 2070 2071 year = getParameter(request, yeararg) 2072 month = getParameter(request, montharg) 2073 day = getParameter(request, dayarg) 2074 if year and month and day: 2075 return Date((int(year), int(month), int(day))) 2076 else: 2077 return None 2078 2079 def getFormMonthPair(request, yeararg, montharg): 2080 2081 """ 2082 Return the month from the 'request' for the calendar with the given 2083 'calendar_name' using the parameters having the given 'yeararg' and 2084 'montharg' names. 2085 """ 2086 2087 year = getParameter(request, yeararg) 2088 month = getParameter(request, montharg) 2089 if year and month: 2090 return Month((int(year), int(month))) 2091 else: 2092 return None 2093 2094 def getFullDateLabel(request, date): 2095 2096 """ 2097 Return the full month plus year label using the given 'request' and 2098 'year_month'. 2099 """ 2100 2101 if not date: 2102 return "" 2103 2104 _ = request.getText 2105 year, month, day = date.as_tuple()[:3] 2106 start_weekday, number_of_days = date.month_properties() 2107 weekday = (start_weekday + day - 1) % 7 2108 day_label = _(getDayLabel(weekday)) 2109 month_label = _(getMonthLabel(month)) 2110 return "%s %s %s %s" % (day_label, day, month_label, year) 2111 2112 def getFullMonthLabel(request, year_month): 2113 2114 """ 2115 Return the full month plus year label using the given 'request' and 2116 'year_month'. 2117 """ 2118 2119 if not year_month: 2120 return "" 2121 2122 _ = request.getText 2123 year, month = year_month.as_tuple()[:2] 2124 month_label = _(getMonthLabel(month)) 2125 return "%s %s" % (month_label, year) 2126 2127 # Page-related functions. 2128 2129 def getPrettyPageName(page): 2130 2131 "Return a nicely formatted title/name for the given 'page'." 2132 2133 title = page.split_title(force=1) 2134 return getPrettyTitle(title) 2135 2136 def linkToPage(request, page, text, query_string=None): 2137 2138 """ 2139 Using 'request', return a link to 'page' with the given link 'text' and 2140 optional 'query_string'. 2141 """ 2142 2143 text = wikiutil.escape(text) 2144 return page.link_to_raw(request, text, query_string) 2145 2146 def getFullPageName(parent, title): 2147 2148 """ 2149 Return a full page name from the given 'parent' page (can be empty or None) 2150 and 'title' (a simple page name). 2151 """ 2152 2153 if parent: 2154 return "%s/%s" % (parent.rstrip("/"), title) 2155 else: 2156 return title 2157 2158 def fillEventPageFromTemplate(template_page, new_page, event_details, category_pagenames): 2159 2160 """ 2161 Using the given 'template_page', complete the 'new_page' by copying the 2162 template and adding the given 'event_details' (a dictionary of event 2163 fields), setting also the 'category_pagenames' to define category 2164 membership. 2165 """ 2166 2167 event_page = EventPage(template_page) 2168 new_event_page = EventPage(new_page) 2169 new_event_page.copyPage(event_page) 2170 2171 if new_event_page.getFormat() == "wiki": 2172 new_event = Event(new_event_page, event_details) 2173 new_event_page.setEvents([new_event]) 2174 new_event_page.setCategoryMembership(category_pagenames) 2175 new_event_page.saveChanges() 2176 2177 # Formatting-related functions. 2178 2179 def getParserClass(request, format): 2180 2181 """ 2182 Return a parser class using the 'request' for the given 'format', returning 2183 a plain text parser if no parser can be found for the specified 'format'. 2184 """ 2185 2186 try: 2187 return wikiutil.searchAndImportPlugin(request.cfg, "parser", format or "plain") 2188 except wikiutil.PluginMissingError: 2189 return wikiutil.searchAndImportPlugin(request.cfg, "parser", "plain") 2190 2191 def getFormatter(request, mimetype, page): 2192 2193 """ 2194 Return a formatter using the given 'request' for the given 'mimetype' for 2195 use on the indicated 'page'. 2196 """ 2197 2198 try: 2199 cls = wikiutil.searchAndImportPlugin(request.cfg, "formatter", mimetype) 2200 except wikiutil.PluginMissingError: 2201 cls = wikiutil.searchAndImportPlugin(request.cfg, "formatter", "text/plain") 2202 fmt = request.formatter = page.formatter = cls(request) 2203 fmt.setPage(page) 2204 return fmt 2205 2206 def formatText(text, request, fmt, parser_cls): 2207 2208 """ 2209 Format the given 'text' using the specified 'request', formatter 'fmt' and 2210 parser class 'parser_cls'. 2211 """ 2212 2213 # Suppress line anchors. 2214 2215 parser = parser_cls(text, request, line_anchors=False) 2216 2217 # Fix lists by indicating that a paragraph is already started. 2218 2219 return request.redirectedOutput(parser.format, fmt, inhibit_p=True) 2220 2221 # vim: tabstop=4 expandtab shiftwidth=4