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