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