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