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