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