1 # -*- coding: iso-8859-1 -*- 2 """ 3 MoinMoin - EventAggregator library 4 5 @copyright: 2008, 2009, 2010 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 19 __version__ = "0.5" 20 21 # Date labels. 22 23 month_labels = ["January", "February", "March", "April", "May", "June", 24 "July", "August", "September", "October", "November", "December"] 25 weekday_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 26 27 # Regular expressions where MoinMoin does not provide the required support. 28 29 category_regexp = None 30 31 # Page parsing. 32 33 definition_list_regexp = re.compile(ur'(?P<wholeterm>^(?P<optcomment>#*)\s+(?P<term>.*?)::\s)(?P<desc>.*?)$', re.UNICODE | re.MULTILINE) 34 category_membership_regexp = re.compile(ur"^\s*((Category\S+)(\s+Category\S+)*)\s*$", re.MULTILINE | re.UNICODE) 35 36 # Value parsing. 37 38 date_regexp = re.compile(ur'(?P<year>[0-9]{4})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})', re.UNICODE) 39 month_regexp = re.compile(ur'(?P<year>[0-9]{4})-(?P<month>[0-9]{2})', re.UNICODE) 40 verbatim_regexp = re.compile(ur'(?:' 41 ur'<<Verbatim\((?P<verbatim>.*?)\)>>' 42 ur'|' 43 ur'\[\[Verbatim\((?P<verbatim2>.*?)\)\]\]' 44 ur'|' 45 ur'`(?P<monospace>.*?)`' 46 ur'|' 47 ur'{{{(?P<preformatted>.*?)}}}' 48 ur')', re.UNICODE) 49 50 # Utility functions. 51 52 def isMoin15(): 53 return version.release.startswith("1.5.") 54 55 def getCategoryPattern(request): 56 global category_regexp 57 58 try: 59 return request.cfg.cache.page_category_regexact 60 except AttributeError: 61 62 # Use regular expression from MoinMoin 1.7.1 otherwise. 63 64 if category_regexp is None: 65 category_regexp = re.compile(u'^%s$' % ur'(?P<all>Category(?P<key>(?!Template)\S+))', re.UNICODE) 66 return category_regexp 67 68 # Textual representations. 69 70 def getHTTPTimeString(tmtuple): 71 return "%s, %02d %s %04d %02d:%02d:%02d GMT" % ( 72 weekday_labels[tmtuple.tm_wday], 73 tmtuple.tm_mday, 74 month_labels[tmtuple.tm_mon -1], # zero-based labels 75 tmtuple.tm_year, 76 tmtuple.tm_hour, 77 tmtuple.tm_min, 78 tmtuple.tm_sec 79 ) 80 81 def getSimpleWikiText(text): 82 83 """ 84 Return the plain text representation of the given 'text' which may employ 85 certain Wiki syntax features, such as those providing verbatim or monospaced 86 text. 87 """ 88 89 # NOTE: Re-implementing support for verbatim text and linking avoidance. 90 91 return "".join([s for s in verbatim_regexp.split(text) if s is not None]) 92 93 def getEncodedWikiText(text): 94 95 "Encode the given 'text' in a verbatim representation." 96 97 return "<<Verbatim(%s)>>" % text 98 99 def getPrettyTitle(title): 100 101 "Return a nicely formatted version of the given 'title'." 102 103 return title.replace("_", " ").replace("/", u" ? ") 104 105 def getMonthLabel(month): 106 107 "Return an unlocalised label for the given 'month'." 108 109 return month_labels[month - 1] # zero-based labels 110 111 def getDayLabel(weekday): 112 113 "Return an unlocalised label for the given 'weekday'." 114 115 return weekday_labels[weekday] 116 117 # Action support functions. 118 119 def getPageRevision(page): 120 121 "Return the revision details dictionary for the given 'page'." 122 123 # From Page.edit_info... 124 125 if hasattr(page, "editlog_entry"): 126 line = page.editlog_entry() 127 else: 128 line = page._last_edited(page.request) # MoinMoin 1.5.x and 1.6.x 129 130 timestamp = line.ed_time_usecs 131 mtime = wikiutil.version2timestamp(long(timestamp)) # must be long for py 2.2.x 132 return {"timestamp" : time.gmtime(mtime), "comment" : line.comment} 133 134 # Category discovery and searching. 135 136 def getCategories(request): 137 138 """ 139 From the AdvancedSearch macro, return a list of category page names using 140 the given 'request'. 141 """ 142 143 # This will return all pages with "Category" in the title. 144 145 cat_filter = getCategoryPattern(request).search 146 return request.rootpage.getPageList(filter=cat_filter) 147 148 def getCategoryMapping(category_pagenames, request): 149 150 """ 151 For the given 'category_pagenames' return a list of tuples of the form 152 (category name, category page name) using the given 'request'. 153 """ 154 155 cat_pattern = getCategoryPattern(request) 156 mapping = [] 157 for pagename in category_pagenames: 158 name = cat_pattern.match(pagename).group("key") 159 if name != "Category": 160 mapping.append((name, pagename)) 161 mapping.sort() 162 return mapping 163 164 def getCategoryPages(pagename, request): 165 166 """ 167 Return the pages associated with the given category 'pagename' using the 168 'request'. 169 """ 170 171 query = search.QueryParser().parse_query('category:%s' % pagename) 172 if isMoin15(): 173 results = search.searchPages(request, query) 174 results.sortByPagename() 175 else: 176 results = search.searchPages(request, query, "page_name") 177 178 cat_pattern = getCategoryPattern(request) 179 pages = [] 180 for page in results.hits: 181 if not cat_pattern.match(page.page_name): 182 pages.append(page) 183 return pages 184 185 # The main activity functions. 186 187 class EventPage: 188 189 "An event page." 190 191 def __init__(self, page): 192 self.page = page 193 self.details = None 194 self.body = None 195 self.categories = None 196 197 def copyPage(self, page): 198 199 "Copy the body of the given 'page'." 200 201 self.body = page.getBody() 202 203 def getPageURL(self, request): 204 205 "Using 'request', return the URL of this page." 206 207 page = self.page 208 209 if isMoin15(): 210 return request.getQualifiedURL(page.url(request)) 211 else: 212 return request.getQualifiedURL(page.url(request, relative=0)) 213 214 def getFormat(self): 215 216 "Get the format used on this page." 217 218 if isMoin15(): 219 return "wiki" # page.pi_format 220 else: 221 return self.page.pi["format"] 222 223 def getRevisions(self): 224 225 "Return a list of page revisions." 226 227 return self.page.getRevList() 228 229 def getPageRevision(self): 230 231 "Return the revision details dictionary for this page." 232 233 return getPageRevision(self.page) 234 235 def getPageName(self): 236 237 "Return the page name." 238 239 return self.page.page_name 240 241 def getPrettyPageName(self): 242 243 "Return a nicely formatted title/name for this page." 244 245 return getPrettyPageName(self.page) 246 247 def getBody(self): 248 249 "Get the current page body." 250 251 if self.body is None: 252 self.body = self.page.get_raw_body() 253 return self.body 254 255 def getEventDetails(self): 256 257 "Return a dictionary of event details from this page." 258 259 if self.details is None: 260 self.details = {} 261 262 if self.getFormat() == "wiki": 263 for match in definition_list_regexp.finditer(self.getBody()): 264 265 # Skip commented-out items. 266 267 if match.group("optcomment"): 268 continue 269 270 # Permit case-insensitive list terms. 271 272 term = match.group("term").lower() 273 desc = match.group("desc") 274 275 # Special value type handling. 276 277 # Dates. 278 279 if term in ("start", "end"): 280 desc = getDate(desc) 281 282 # Lists (whose elements may be quoted). 283 284 elif term in ("topics", "categories"): 285 desc = [getSimpleWikiText(value.strip()) for value in desc.split(",")] 286 287 # Labels which may well be quoted. 288 289 elif term in ("title", "summary", "description"): 290 desc = getSimpleWikiText(desc) 291 292 if desc is not None: 293 self.details[term] = desc 294 295 return self.details 296 297 def getCategoryMembership(self): 298 299 "Get the category names from this page." 300 301 if self.categories is None: 302 body = self.getBody() 303 match = category_membership_regexp.search(body) 304 self.categories = match.findall().split() 305 306 return self.categories 307 308 def getEventSummary(self, event_parent=None): 309 310 """ 311 Return either the given title or summary of the event described by this 312 page, according to the page's event details, or using the pretty version 313 of the page name. 314 315 If the optional 'event_parent' is specified, any page beneath the given 316 'event_parent' page in the page hierarchy will omit this parent information 317 if its name is used as the summary. 318 """ 319 320 event_details = self.getEventDetails() 321 322 if event_details.has_key("title"): 323 return event_details["title"] 324 elif event_details.has_key("summary"): 325 return event_details["summary"] 326 else: 327 # If appropriate, remove the parent details and "/" character. 328 329 title = self.getPageName() 330 331 if event_parent is not None and title.startswith(event_parent): 332 title = title[len(event_parent.rstrip("/")) + 1:] 333 334 return getPrettyTitle(title) 335 336 def setEventDetails(self, event_details): 337 338 "Set the 'event_details' for this page." 339 340 self.details = event_details 341 342 def setCategoryMembership(self, category_names): 343 344 """ 345 Set the category membership for the page using the specified 346 'category_names'. 347 """ 348 349 self.categories = category_names 350 351 def flushEventDetails(self): 352 353 "Flush the current event details to this page's body text." 354 355 new_body_parts = [] 356 end_of_last_match = 0 357 body = self.getBody() 358 event_details = self.getEventDetails() 359 360 for match in definition_list_regexp.finditer(body): 361 362 # Add preceding text to the new body. 363 364 new_body_parts.append(body[end_of_last_match:match.start()]) 365 end_of_last_match = match.end() 366 367 # Get the matching regions, adding the term to the new body. 368 369 new_body_parts.append(match.group("wholeterm")) 370 371 # Permit case-insensitive list terms. 372 373 term = match.group("term").lower() 374 desc = match.group("desc") 375 376 # Special value type handling. 377 378 if event_details.has_key(term): 379 380 # Dates. 381 382 if term in ("start", "end"): 383 desc = desc.replace("YYYY-MM-DD", str(event_details[term])) 384 385 # Lists (whose elements may be quoted). 386 387 elif term in ("topics", "categories"): 388 desc = ", ".join(getEncodedWikiText(event_details[term])) 389 390 # Labels which may well be quoted. 391 392 elif term in ("title", "summary"): 393 desc = getEncodedWikiText(event_details[term]) 394 395 # Text which need not be quoted, but it will be Wiki text. 396 397 elif term in ("description",): 398 desc = event_details[term] 399 400 new_body_parts.append(desc) 401 402 else: 403 new_body_parts.append(body[end_of_last_match:]) 404 405 self.body = "".join(new_body_parts) 406 407 def flushCategoryMembership(self): 408 409 "Flush the category membership to the page body." 410 411 body = self.getBody() 412 category_names = self.getCategoryMembership() 413 match = category_membership_regexp.search(body) 414 415 if match: 416 self.body = "".join([body[:match.start()], " ".join(category_names), body[match.end():]]) 417 418 def saveChanges(self): 419 420 "Save changes to the event." 421 422 self.flushEventDetails() 423 self.flushCategoryMembership() 424 self.page.saveText(self.getBody(), 0) 425 426 def linkToPage(self, request, text, query_string=None): 427 428 """ 429 Using 'request', return a link to this page with the given link 'text' 430 and optional 'query_string'. 431 """ 432 433 return linkToPage(request, self.page, text, query_string) 434 435 def getEvents(request, category_names, calendar_start=None, calendar_end=None): 436 437 """ 438 Using the 'request', generate a list of events found on pages belonging to 439 the specified 'category_names', using the optional 'calendar_start' and 440 'calendar_end' month tuples of the form (year, month) to indicate a window 441 of interest. 442 443 Return a list of events, a dictionary mapping months to event lists (within 444 the window of interest), a list of all events within the window of interest, 445 the earliest month of an event within the window of interest, and the latest 446 month of an event within the window of interest. 447 """ 448 449 # Re-order the window, if appropriate. 450 451 if calendar_start is not None and calendar_end is not None and calendar_start > calendar_end: 452 calendar_start, calendar_end = calendar_end, calendar_start 453 454 events = [] 455 shown_events = {} 456 all_shown_events = [] 457 processed_pages = set() 458 459 earliest = None 460 latest = None 461 462 for category_name in category_names: 463 464 # Get the pages and page names in the category. 465 466 pages_in_category = getCategoryPages(category_name, request) 467 468 # Visit each page in the category. 469 470 for page_in_category in pages_in_category: 471 pagename = page_in_category.page_name 472 473 # Only process each page once. 474 475 if pagename in processed_pages: 476 continue 477 else: 478 processed_pages.add(pagename) 479 480 # Get a real page, not a result page. 481 482 event_page = EventPage(Page(request, pagename)) 483 event_details = event_page.getEventDetails() 484 485 # Remember the event page. 486 487 events.append(event_page) 488 489 # Test for the suitability of the event. 490 491 if event_details.has_key("start") and event_details.has_key("end"): 492 493 start_month = event_details["start"].as_month() 494 end_month = event_details["end"].as_month() 495 496 # Compare the months of the dates to the requested calendar 497 # window, if any. 498 499 if (calendar_start is None or end_month >= calendar_start) and \ 500 (calendar_end is None or start_month <= calendar_end): 501 502 all_shown_events.append(event_page) 503 504 if earliest is None or start_month < earliest: 505 earliest = start_month 506 if latest is None or end_month > latest: 507 latest = end_month 508 509 # Store the event in the month-specific dictionary. 510 511 first = max(start_month, calendar_start or start_month) 512 last = min(end_month, calendar_end or end_month) 513 514 for event_month in first.months_until(last): 515 if not shown_events.has_key(event_month): 516 shown_events[event_month] = [] 517 shown_events[event_month].append(event_page) 518 519 return events, shown_events, all_shown_events, earliest, latest 520 521 def setEventTimestamps(request, events): 522 523 """ 524 Using 'request', set timestamp details in the details dictionary of each of 525 the 'events'. 526 527 Retutn the latest timestamp found. 528 """ 529 530 latest = None 531 532 for event_page in events: 533 event_details = event_page.getEventDetails() 534 535 # Get the initial revision of the page. 536 537 revisions = event_page.getRevisions() 538 event_page_initial = Page(request, event_page.getPageName(), rev=revisions[-1]) 539 540 # Get the created and last modified times. 541 542 initial_revision = getPageRevision(event_page_initial) 543 event_details["created"] = initial_revision["timestamp"] 544 latest_revision = event_page.getPageRevision() 545 event_details["last-modified"] = latest_revision["timestamp"] 546 event_details["sequence"] = len(revisions) - 1 547 event_details["last-comment"] = latest_revision["comment"] 548 549 if latest is None or latest < event_details["last-modified"]: 550 latest = event_details["last-modified"] 551 552 return latest 553 554 def compareEvents(event1, event2): 555 556 "Compare 'event1' and 'event2' by start and end date." 557 558 event_details1 = event1.getEventDetails() 559 event_details2 = event2.getEventDetails() 560 return cmp( 561 (event_details1["start"], event_details1["end"]), 562 (event_details2["start"], event_details2["end"]) 563 ) 564 565 def getOrderedEvents(events): 566 567 """ 568 Return a list with the given 'events' ordered according to their start and 569 end dates. 570 """ 571 572 ordered_events = events[:] 573 ordered_events.sort(compareEvents) 574 return ordered_events 575 576 def getConcretePeriod(calendar_start, calendar_end, earliest, latest): 577 578 """ 579 From the requested 'calendar_start' and 'calendar_end', which may be None, 580 indicating that no restriction is imposed on the period for each of the 581 boundaries, use the 'earliest' and 'latest' event months to define a 582 specific period of interest. 583 """ 584 585 # Define the period as starting with any specified start month or the 586 # earliest event known, ending with any specified end month or the latest 587 # event known. 588 589 first = calendar_start or earliest 590 last = calendar_end or latest 591 592 # If there is no range of months to show, perhaps because there are no 593 # events in the requested period, and there was no start or end month 594 # specified, show only the month indicated by the start or end of the 595 # requested period. If all events were to be shown but none were found show 596 # the current month. 597 598 if first is None: 599 first = last or getCurrentMonth() 600 if last is None: 601 last = first or getCurrentMonth() 602 603 # Permit "expiring" periods (where the start date approaches the end date). 604 605 return min(first, last), last 606 607 def getCoverage(start, end, events): 608 609 """ 610 Within the period defined by the 'start' and 'end' dates, determine the 611 coverage of the days in the period by the given 'events', returning a set of 612 covered days, along with a list of slots, where each slot contains a tuple 613 of the form (set of covered days, events). 614 """ 615 616 all_events = [] 617 full_coverage = set() 618 619 # Get event details. 620 621 for event_page in events: 622 event_details = event_page.getEventDetails() 623 624 # Test for the event in the period. 625 626 if event_details["start"] <= end and event_details["end"] >= start: 627 628 # Find the coverage of this period for the event. 629 630 event_start = max(event_details["start"], start) 631 event_end = min(event_details["end"], end) 632 event_coverage = set(event_start.days_until(event_end)) 633 634 # Update the overall coverage. 635 636 full_coverage.update(event_coverage) 637 638 # Try and fit the event into the events list. 639 640 for i, (coverage, covered_events) in enumerate(all_events): 641 642 # Where the event does not overlap with the current 643 # element, add it alongside existing events. 644 645 if not coverage.intersection(event_coverage): 646 covered_events.append(event_page) 647 all_events[i] = coverage.union(event_coverage), covered_events 648 break 649 650 # Make a new element in the list if the event cannot be 651 # marked alongside existing events. 652 653 else: 654 all_events.append((event_coverage, [event_page])) 655 656 return full_coverage, all_events 657 658 # Date-related functions. 659 660 class Period: 661 662 "A simple period of time." 663 664 def __init__(self, data): 665 self.data = data 666 667 def months(self): 668 return self.data[0] * 12 + self.data[1] 669 670 class Month: 671 672 "A simple year-month representation." 673 674 def __init__(self, data): 675 self.data = tuple(data) 676 677 def __repr__(self): 678 return "%s(%r)" % (self.__class__.__name__, self.data) 679 680 def __str__(self): 681 return "%04d-%02d" % self.as_tuple()[:2] 682 683 def __hash__(self): 684 return hash(self.as_tuple()) 685 686 def as_tuple(self): 687 return self.data 688 689 def as_date(self, day): 690 return Date(self.as_tuple() + (day,)) 691 692 def year(self): 693 return self.data[0] 694 695 def month(self): 696 return self.data[1] 697 698 def month_properties(self): 699 700 """ 701 Return the weekday of the 1st of the month, along with the number of 702 days, as a tuple. 703 """ 704 705 year, month = self.data 706 return calendar.monthrange(year, month) 707 708 def month_update(self, n=1): 709 710 "Return the month updated by 'n' months." 711 712 year, month = self.data 713 return Month((year + (month - 1 + n) / 12, (month - 1 + n) % 12 + 1)) 714 715 def next_month(self): 716 717 "Return the month following this one." 718 719 return self.month_update(1) 720 721 def previous_month(self): 722 723 "Return the month preceding this one." 724 725 return self.month_update(-1) 726 727 def __sub__(self, start): 728 729 """ 730 Return the difference in years and months between this month and the 731 'start' month as a period. 732 """ 733 734 return Period([(x - y) for x, y in zip(self.data, start.data)]) 735 736 def __cmp__(self, other): 737 return cmp(self.data, other.data) 738 739 def until(self, end, nextfn, prevfn): 740 month = self 741 months = [month] 742 if month < end: 743 while month < end: 744 month = nextfn(month) 745 months.append(month) 746 elif month > end: 747 while month > end: 748 month = prevfn(month) 749 months.append(month) 750 return months 751 752 def months_until(self, end): 753 return self.until(end, Month.next_month, Month.previous_month) 754 755 class Date(Month): 756 757 "A simple year-month-day representation." 758 759 def __str__(self): 760 return "%04d-%02d-%02d" % self.as_tuple()[:3] 761 762 def as_month(self): 763 return Month(self.data[:2]) 764 765 def day(self): 766 return self.data[2] 767 768 def next_day(self): 769 770 "Return the date following this one." 771 772 year, month, day = self.data 773 _wd, end_day = calendar.monthrange(year, month) 774 if day == end_day: 775 if month == 12: 776 return Date((year + 1, 1, 1)) 777 else: 778 return Date((year, month + 1, 1)) 779 else: 780 return Date((year, month, day + 1)) 781 782 def previous_day(self): 783 784 "Return the date preceding this one." 785 786 year, month, day = self.data 787 if day == 1: 788 if month == 1: 789 return Date((year - 1, 12, 31)) 790 else: 791 _wd, end_day = calendar.monthrange(year, month - 1) 792 return Date((year, month - 1, end_day)) 793 else: 794 return Date((year, month, day - 1)) 795 796 def days_until(self, end): 797 return self.until(end, Date.next_day, Date.previous_day) 798 799 def getDate(s): 800 801 "Parse the string 's', extracting and returning a date string." 802 803 m = date_regexp.search(s) 804 if m: 805 return Date(map(int, m.groups())) 806 else: 807 return None 808 809 def getMonth(s): 810 811 "Parse the string 's', extracting and returning a month string." 812 813 m = month_regexp.search(s) 814 if m: 815 return Month(map(int, m.groups())) 816 else: 817 return None 818 819 def getCurrentMonth(): 820 821 "Return the current month as a (year, month) tuple." 822 823 today = datetime.date.today() 824 return Month((today.year, today.month)) 825 826 def getCurrentYear(): 827 828 "Return the current year." 829 830 today = datetime.date.today() 831 return today.year 832 833 # User interface functions. 834 835 def getParameter(request, name, default=None): 836 return request.form.get(name, [default])[0] 837 838 def getQualifiedParameter(request, calendar_name, argname, default=None): 839 argname = getQualifiedParameterName(calendar_name, argname) 840 return getParameter(request, argname, default) 841 842 def getQualifiedParameterName(calendar_name, argname): 843 if calendar_name is None: 844 return argname 845 else: 846 return "%s-%s" % (calendar_name, argname) 847 848 def getParameterMonth(arg): 849 850 "Interpret 'arg', recognising keywords and simple arithmetic operations." 851 852 n = None 853 854 if arg.startswith("current"): 855 date = getCurrentMonth() 856 if len(arg) > 8: 857 n = int(arg[7:]) 858 859 elif arg.startswith("yearstart"): 860 date = Month((getCurrentYear(), 1)) 861 if len(arg) > 10: 862 n = int(arg[9:]) 863 864 elif arg.startswith("yearend"): 865 date = Month((getCurrentYear(), 12)) 866 if len(arg) > 8: 867 n = int(arg[7:]) 868 869 else: 870 date = getMonth(arg) 871 872 if n is not None: 873 date = date.month_update(n) 874 875 return date 876 877 def getFormMonth(request, calendar_name, argname): 878 879 """ 880 Return the month from the 'request' for the calendar with the given 881 'calendar_name' using the parameter having the given 'argname'. 882 """ 883 884 arg = getQualifiedParameter(request, calendar_name, argname) 885 if arg is not None: 886 return getParameterMonth(arg) 887 else: 888 return None 889 890 def getFormMonthPair(request, yeararg, montharg): 891 892 """ 893 Return the month from the 'request' for the calendar with the given 894 'calendar_name' using the parameters having the given 'yeararg' and 895 'montharg' names. 896 """ 897 898 year = getParameter(request, yeararg) 899 month = getParameter(request, montharg) 900 if year and month: 901 return Month((int(year), int(month))) 902 else: 903 return None 904 905 # Page-related functions. 906 907 def getPrettyPageName(page): 908 909 "Return a nicely formatted title/name for the given 'page'." 910 911 if isMoin15(): 912 title = page.split_title(page.request, force=1) 913 else: 914 title = page.split_title(force=1) 915 916 return getPrettyTitle(title) 917 918 def linkToPage(request, page, text, query_string=None): 919 920 """ 921 Using 'request', return a link to 'page' with the given link 'text' and 922 optional 'query_string'. 923 """ 924 925 text = wikiutil.escape(text) 926 927 if isMoin15(): 928 url = wikiutil.quoteWikinameURL(page.page_name) 929 if query_string is not None: 930 url = "%s?%s" % (url, query_string) 931 return wikiutil.link_tag(request, url, text, getattr(page, "formatter", None)) 932 else: 933 return page.link_to_raw(request, text, query_string) 934 935 # vim: tabstop=4 expandtab shiftwidth=4