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