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