1 # -*- coding: iso-8859-1 -*- 2 """ 3 MoinMoin - ImprovedTableParser library 4 5 @copyright: 2012 by Paul Boddie <paul@boddie.org.uk> 6 @copyright: 2008 MoinMoin:FlorianKrupicka (redirectedOutput code) 7 @license: GNU GPL (v2 or later), see COPYING.txt for details. 8 """ 9 10 from MoinMoin import wikiutil 11 from shlex import shlex 12 from StringIO import StringIO 13 from MoinSupport import * 14 import re 15 16 # Regular expressions. 17 18 syntax = { 19 # For section markers. 20 "markers" : (r"(?P<n>\\+)(?P<b>{|})(?P=n)(?P=b)(?P=n)(?P=b)", re.MULTILINE), 21 "marker" : (r"(\\+)", 0), 22 23 # At start of line: 24 "rows" : (r"^==(?!.*?==$)", re.MULTILINE), # == not-heading 25 26 # Within text: 27 "sections" : (r"({{{.*?}}})", re.MULTILINE | re.DOTALL), # {{{ ... }}} 28 "columns" : (r"\|\|[ \t]*", 0), # || ws-excl-nl 29 30 # At start of column text: 31 "column" : (r"^\s*<(.*?)>\s*(.*)", re.DOTALL), # ws < attributes > ws 32 } 33 34 patterns = {} 35 for name, (value, flags) in syntax.items(): 36 patterns[name] = re.compile(value, re.UNICODE | flags) 37 38 # Other regular expressions. 39 40 leading_number_regexp = re.compile(r"\d*") 41 42 # Constants. 43 44 up_arrow = u'\u2191' 45 down_arrow = u'\u2193' 46 47 # Functions. 48 49 def parse(s): 50 51 "Parse 's', returning a table definition." 52 53 s = replaceMarkers(s) 54 55 table_attrs = {} 56 rows = [] 57 58 # The following will be redefined upon the construction of the first row. 59 60 row_attrs = {} 61 columns = [] 62 columnnumber = 0 63 64 # The following will be redefined upon the construction of the first column. 65 66 column_attrs = {} 67 68 # Process exposed text and sections. 69 70 exposed = True 71 72 # Initially, start a new row. 73 74 row_continued = False 75 76 for region in patterns["sections"].split(s): 77 78 # Only look for table features in exposed text. 79 80 if exposed: 81 82 # Extract each row from the definition. 83 84 for row_text in patterns["rows"].split(region): 85 86 # Only create a new row when a boundary has been found. 87 88 if not row_continued: 89 90 # Complete any existing row. 91 92 if columns: 93 extractAttributes(columns, row_attrs, table_attrs) 94 span_columns(columns, columnnumber) 95 96 # Replicate the last row to determine column usage. 97 98 column_usage = [] 99 100 for column_attrs, text in columns: 101 rowspan = int(strip_token(column_attrs.get("rowspan", "1"))) 102 if rowspan > 1: 103 attrs = {} 104 attrs.update(column_attrs) 105 attrs["rowspan"] = str(rowspan - 1) 106 attrs["rowcontinuation"] = True 107 column_usage.append((attrs, text)) 108 else: 109 column_usage.append(({}, None)) 110 111 columns = column_usage 112 113 # Define a new collection of row attributes. 114 115 row_attrs = {} 116 117 # Reset the columns and make the list available for the 118 # addition of new columns, starting a new column 119 # immediately. 120 121 rows.append((row_attrs, columns)) 122 column_continued = False 123 columnnumber = 0 124 125 # Extract each column from the row. 126 127 for text in patterns["columns"].split(row_text): 128 129 # Only create a new column when a boundary has been found. 130 131 if not column_continued: 132 133 # Complete any existing column. 134 135 if columns: 136 columnnumber = span_columns(columns, columnnumber) 137 138 # Extract the attribute and text sections. 139 140 match = patterns["column"].search(text) 141 if match: 142 attribute_text, text = match.groups() 143 column_attrs = parseAttributes(attribute_text, True) 144 else: 145 column_attrs = {} 146 147 # Define the new column with a mutable container 148 # permitting the extension of the text. 149 150 details = [column_attrs, text] 151 152 # Find the next gap in the columns. 153 154 while columnnumber != -1 and columnnumber < len(columns): 155 attrs, text = columns[columnnumber] 156 if text is None: 157 columns[columnnumber] = details 158 break 159 columnnumber += 1 160 161 # Or start adding at the end of the row. 162 163 else: 164 columnnumber = -1 165 columns.append(details) 166 167 else: 168 columns[columnnumber][1] += text 169 170 # Permit columns immediately following this one. 171 172 column_continued = False 173 174 # Permit a continuation of the current column. 175 176 column_continued = True 177 178 # Permit rows immediately following this one. 179 180 row_continued = False 181 182 # Permit a continuation if the current row. 183 184 row_continued = True 185 186 # Write any section into the current column. 187 188 else: 189 columns[columnnumber][1] += region 190 191 exposed = not exposed 192 193 # Complete any final row. 194 195 if columns: 196 extractAttributes(columns, row_attrs, table_attrs) 197 198 return table_attrs, rows 199 200 def span_columns(columns, columnnumber): 201 202 """ 203 In the 'columns', make the column with the 'columnnumber' span the specified 204 number of columns, returning the next appropriate column number. 205 """ 206 207 column_attrs, text = columns[columnnumber] 208 209 # Handle any previous column spanning other columns. 210 211 if column_attrs.has_key("colspan"): 212 colspan = int(strip_token(column_attrs["colspan"])) 213 214 # Duplicate the current column as continuation 215 # columns for as long as the colspan is defined. 216 217 colspan -= 1 218 while colspan > 0: 219 attrs = {} 220 attrs.update(column_attrs) 221 attrs["colspan"] = str(colspan) 222 attrs["colcontinuation"] = True 223 224 if columnnumber != -1: 225 columnnumber += 1 226 if columnnumber < len(columns): 227 columns[columnnumber] = attrs, text 228 else: 229 columnnumber = -1 230 231 if columnnumber == -1: 232 columns.append((attrs, text)) 233 234 colspan -= 1 235 236 return columnnumber 237 238 def extractAttributes(columns, row_attrs, table_attrs): 239 240 """ 241 Extract row- and table-level attributes from 'columns', storing them in 242 'row_attrs' and 'table_attrs' respectively. 243 """ 244 245 for column in columns: 246 attrs = column[0] 247 for name, value in attrs.items(): 248 if name.startswith("row") and name not in ("rowspan", "rowcontinuation"): 249 row_attrs[name] = value 250 del attrs[name] 251 elif name.startswith("table"): 252 table_attrs[name] = value 253 del attrs[name] 254 255 def replaceMarkers(s): 256 257 "Convert the section notation in 's'." 258 259 l = [] 260 last = 0 261 262 # Get each marker and convert it. 263 264 for match in patterns["markers"].finditer(s): 265 start, stop = match.span() 266 l.append(s[last:start]) 267 268 # Convert the marker. 269 270 marker = [] 271 brace = True 272 for text in patterns["marker"].split(match.group()): 273 if brace: 274 marker.append(text) 275 else: 276 marker.append(text[:-1]) 277 brace = not brace 278 279 l.append("".join(marker)) 280 last = stop 281 else: 282 l.append(s[last:]) 283 284 return "".join(l) 285 286 def parseAttributes(s, escape=True): 287 288 """ 289 Parse the table attributes string 's', returning a mapping of names to 290 values. If 'escape' is set to a true value, the attributes will be suitable 291 for use with the formatter API. If 'escape' is set to a false value, the 292 attributes will have any quoting removed. 293 """ 294 295 attrs = {} 296 f = StringIO(s) 297 name = None 298 need_value = False 299 300 for token in shlex(f): 301 302 # Capture the name if needed. 303 304 if name is None: 305 name = escape and wikiutil.escape(token) or strip_token(token) 306 307 # Detect either an equals sign or another name. 308 309 elif not need_value: 310 if token == "=": 311 need_value = True 312 else: 313 attrs[name.lower()] = escape and "true" or True 314 name = wikiutil.escape(token) 315 316 # Otherwise, capture a value. 317 318 else: 319 # Quoting of attributes done similarly to wikiutil.parseAttributes. 320 321 if token: 322 if escape: 323 if token[0] in ("'", '"'): 324 token = wikiutil.escape(token) 325 else: 326 token = '"%s"' % wikiutil.escape(token, 1) 327 else: 328 token = strip_token(token) 329 330 attrs[name.lower()] = token 331 name = None 332 need_value = False 333 334 return attrs 335 336 def strip_token(token): 337 338 "Return the given 'token' stripped of quoting." 339 340 if token[0] in ("'", '"') and token[-1] == token[0]: 341 return token[1:-1] 342 else: 343 return token 344 345 # Formatting of embedded content. 346 # NOTE: Borrowed from EventAggregator. 347 348 def getParserClass(request, format): 349 350 """ 351 Return a parser class using the 'request' for the given 'format', returning 352 a plain text parser if no parser can be found for the specified 'format'. 353 """ 354 355 try: 356 return wikiutil.searchAndImportPlugin(request.cfg, "parser", format or "plain") 357 except wikiutil.PluginMissingError: 358 return wikiutil.searchAndImportPlugin(request.cfg, "parser", "plain") 359 360 def getFormatterClass(request, format): 361 362 """ 363 Return a formatter class using the 'request' for the given output 'format', 364 returning a plain text formatter if no formatter can be found for the 365 specified 'format'. 366 """ 367 368 try: 369 return wikiutil.searchAndImportPlugin(request.cfg, "formatter", format or "plain") 370 except wikiutil.PluginMissingError: 371 return wikiutil.searchAndImportPlugin(request.cfg, "formatter", "plain") 372 373 def formatText(text, request, fmt): 374 375 "Format the given 'text' using the specified 'request' and formatter 'fmt'." 376 377 parser_cls = getParserClass(request, request.page.pi["format"]) 378 parser = parser_cls(text, request, line_anchors=False) 379 old_fmt = request.formatter 380 request.formatter = fmt 381 try: 382 return redirectedOutput(request, parser, fmt, inhibit_p=True) 383 finally: 384 request.formatter = old_fmt 385 386 def redirectedOutput(request, parser, fmt, **kw): 387 388 "A fixed version of the request method of the same name." 389 390 buf = StringIO() 391 request.redirect(buf) 392 try: 393 parser.format(fmt, **kw) 394 if hasattr(fmt, "flush"): 395 buf.write(fmt.flush(True)) 396 finally: 397 request.redirect() 398 text = buf.getvalue() 399 buf.close() 400 return text 401 402 # Sorting utilities. 403 404 def get_sort_columns(s, start=0): 405 406 """ 407 Split the comma-separated string 's', extracting the column specifications 408 of the form <column>["n"] where the suffix "n" indicates an optional 409 numeric conversion for that column. Column indexes start from the specified 410 'start' value (defaulting to 0). 411 """ 412 413 sort_columns = [] 414 for column_spec in s.split(","): 415 column_spec = column_spec.strip() 416 417 ascending = True 418 if column_spec.endswith("d"): 419 column_spec = column_spec[:-1] 420 ascending = False 421 422 # Extract the conversion indicator and column index. 423 # Ignore badly-specified columns. 424 425 try: 426 column = get_number(column_spec) 427 suffix = column_spec[len(column):] 428 fn = converters[suffix] 429 sort_columns.append((max(0, int(column) - start), fn, ascending)) 430 except ValueError: 431 pass 432 433 return sort_columns 434 435 def get_column_types(sort_columns): 436 437 """ 438 Return a dictionary mapping column indexes to conversion functions. 439 """ 440 441 d = {} 442 for column, fn, ascending in sort_columns: 443 d[column] = fn, ascending 444 return d 445 446 def get_number(s): 447 448 "From 's', get any leading number." 449 450 match = leading_number_regexp.match(s) 451 if match: 452 return match.group() 453 else: 454 return "" 455 456 def to_number(s, request): 457 458 """ 459 Convert 's' to a number, discarding any non-numeric trailing data. 460 Return an empty string if 's' is empty. 461 """ 462 463 if s: 464 return int(get_number(to_plain_text(s, request))) 465 else: 466 return s 467 468 def to_plain_text(s, request): 469 470 "Convert 's' to plain text." 471 472 fmt = getFormatterClass(request, "plain")(request) 473 fmt.setPage(request.page) 474 return formatText(s, request, fmt) 475 476 converters = { 477 "n" : to_number, 478 "" : to_plain_text, 479 } 480 481 suffixes = {} 482 for key, value in converters.items(): 483 suffixes[value] = key 484 485 class Sorter: 486 487 "A sorting helper class." 488 489 def __init__(self, sort_columns, request): 490 self.sort_columns = sort_columns 491 self.request = request 492 493 def __call__(self, row1, row2): 494 row_attrs1, columns1 = row1 495 row_attrs2, columns2 = row2 496 497 # Apply the conversions to each column, comparing the results. 498 499 for column, fn, ascending in self.sort_columns: 500 column_attrs1, text1 = columns1[column] 501 column_attrs2, text2 = columns2[column] 502 503 # Ignore a column when a conversion is not possible. 504 505 try: 506 value1 = fn(text1, self.request) 507 value2 = fn(text2, self.request) 508 509 # Avoid empty strings appearing earlier than other values. 510 511 if value1 == "" and value2 != "": 512 result = 1 513 elif value1 != "" and value2 == "": 514 result = -1 515 else: 516 result = cmp(value1, value2) 517 518 # Where the columns differ, return a result observing the sense 519 # (ascending or descending) of the comparison for the column. 520 521 if result != 0: 522 return ascending and result or -result 523 524 except ValueError: 525 pass 526 527 return 0 528 529 def write_sort_control(request, columnnumber, columns, sort_columns, column_types, table_name, start=0): 530 531 """ 532 Using the 'request', write a sort control for the given 'columnnumber' in 533 the collection of 'columns', using the existing 'sort_columns' and 534 'column_types' to construct labels and links that modify the sort criteria, 535 and using the given 'table_name' to parameterise the links. 536 """ 537 538 fmt = request.formatter 539 write = request.write 540 _ = request.getText 541 542 write(fmt.div(1, css_class="sortcolumns")) 543 544 write(fmt.paragraph(1)) 545 write(fmt.text(_("Sort by columns..."))) 546 write(fmt.paragraph(0)) 547 548 # Start with the existing criteria without this column being involved. 549 550 revised_sort_columns = [(column, fn, ascending) 551 for (column, fn, ascending) in sort_columns if column != columnnumber] 552 553 # Get the specification of this column. 554 555 columnfn, columnascending = column_types.get(columnnumber, (to_plain_text, True)) 556 newsortcolumn = columnnumber, columnfn, columnascending 557 newsortcolumn_reverse = columnnumber, columnfn, not columnascending 558 newlabel = columns[columnnumber][1].strip() 559 560 # Show this column in all possible places in the sorting criteria. 561 562 write(fmt.number_list(1)) 563 564 just_had_this_column = False 565 566 for i, (column, fn, ascending) in enumerate(sort_columns): 567 new_sort_columns = revised_sort_columns[:] 568 new_sort_columns.insert(i, newsortcolumn) 569 label = columns[column][1].strip() 570 571 arrow = columnascending and down_arrow or up_arrow 572 arrow_reverse = not columnascending and down_arrow or up_arrow 573 574 sortcolumns = get_sort_column_output(new_sort_columns) 575 new_sort_columns[i] = newsortcolumn_reverse 576 sortcolumns_reverse = get_sort_column_output(new_sort_columns) 577 578 # Columns permitting the insertion of the selected column. 579 580 if column != columnnumber and not just_had_this_column: 581 write(fmt.listitem(1, css_class="sortcolumn")) 582 583 # Pop-up element showing the column inserted before the sort column. 584 585 write(fmt.span(1, css_class="sortcolumn-container")) 586 write(fmt.span(1, css_class="newsortcolumn")) 587 write(formatText(newlabel, request, fmt)) 588 589 write_sort_link(write, request, fmt, table_name, sortcolumns, arrow, "sortdirection") 590 write_sort_link(write, request, fmt, table_name, sortcolumns_reverse, arrow_reverse, "sortdirection") 591 592 write(fmt.span(0)) 593 write(fmt.span(0)) 594 595 # Link for selection of the modified sort criteria using the current 596 # column and showing its particular direction. 597 598 arrow = ascending and down_arrow or up_arrow 599 arrow_reverse = not ascending and down_arrow or up_arrow 600 write_sort_link(write, request, fmt, table_name, sortcolumns, u"%s %s" % (label, arrow), "") 601 602 # Columns permitting removal or modification. 603 604 else: 605 write(fmt.listitem(1)) 606 607 # Either show the column without a link, since the column to be 608 # inserted is already before the current column. 609 610 if just_had_this_column: 611 just_had_this_column = False 612 arrow = ascending and down_arrow or up_arrow 613 arrow_reverse = not ascending and down_arrow or up_arrow 614 615 # Write the current column with its particular direction. 616 617 write(fmt.span(1, css_class="unlinkedcolumn")) 618 write(formatText(u"%s %s" % (label, arrow), request, fmt)) 619 write(fmt.span(0)) 620 621 # Or show the column with a link for its removal. 622 623 else: 624 just_had_this_column = True 625 sortcolumns_revised = get_sort_column_output(revised_sort_columns) 626 write_sort_link(write, request, fmt, table_name, sortcolumns_revised, u"%s %s" % (label, arrow), "removecolumn") 627 628 # Alternative sort direction. 629 630 write_sort_link(write, request, fmt, table_name, sortcolumns_reverse, arrow_reverse, "altdirection") 631 632 write(fmt.listitem(0)) 633 634 if not just_had_this_column: 635 636 # Write the sorting criteria with this column at the end. 637 638 new_sort_columns = revised_sort_columns[:] 639 new_sort_columns.append(newsortcolumn) 640 641 sortcolumns = get_sort_column_output(new_sort_columns) 642 new_sort_columns[-1] = newsortcolumn_reverse 643 sortcolumns_reverse = get_sort_column_output(new_sort_columns) 644 645 arrow = columnascending and down_arrow or up_arrow 646 arrow_reverse = not columnascending and down_arrow or up_arrow 647 648 write(fmt.listitem(1, css_class="appendcolumn")) 649 650 # Pop-up element showing the column inserted before the sort column. 651 652 write(fmt.span(1, css_class="newsortcolumn")) 653 write_sort_link(write, request, fmt, table_name, sortcolumns, newlabel, "") 654 write_sort_link(write, request, fmt, table_name, sortcolumns, arrow, "sortdirection") 655 write_sort_link(write, request, fmt, table_name, sortcolumns_reverse, arrow_reverse, "sortdirection") 656 write(fmt.span(0)) 657 658 write(fmt.listitem(0)) 659 660 write(fmt.number_list(0)) 661 662 write(fmt.div(0)) 663 664 def write_sort_link(write, request, fmt, table_name, sortcolumns, label, css_class): 665 666 "Write a link expressing sort criteria." 667 668 write(fmt.url(1, "?%s#%s" % ( 669 wikiutil.makeQueryString("%s-sortcolumns=%s" % (table_name, sortcolumns)), 670 fmt.qualify_id(fmt.sanitize_to_id(table_name)) 671 ), css_class=css_class)) 672 write(formatText(label, request, fmt)) 673 write(fmt.url(0)) 674 675 def get_sort_column_output(columns, start=0): 676 677 "Return the output criteria for the given 'columns' indexed from 'start'." 678 679 return ",".join([("%d%s%s" % (column + start, suffixes[fn], not ascending and "d" or "")) 680 for (column, fn, ascending) in columns]) 681 682 # Common formatting functions. 683 684 def formatTable(text, request, fmt, attrs=None): 685 686 """ 687 Format the given 'text' using the specified 'request' and formatter 'fmt'. 688 The optional 'attrs' can be used to control the presentation of the table. 689 """ 690 691 # Parse the table region. 692 693 table_attrs, table = parse(text) 694 695 # Define the table name and an anchor attribute. 696 697 table_name = attrs.get("name") 698 if table_name: 699 table_attrs["tableid"] = table_name 700 else: 701 table_name = table_attrs.get("tableid") 702 703 # Only attempt to offer sorting capabilities if a table name is specified. 704 705 if table_name: 706 707 # Get the underlying column types. 708 709 column_types = get_column_types(get_sort_columns(attrs.get("columntypes", ""))) 710 711 # Get sorting criteria from the region. 712 713 region_sortcolumns = attrs.get("sortcolumns", "") 714 715 # Update the column types from the sort criteria. 716 717 column_types.update(get_column_types(get_sort_columns(region_sortcolumns))) 718 719 # Determine the applicable sort criteria using the request. 720 721 sortcolumns = getQualifiedParameter(request, table_name, "sortcolumns") 722 if sortcolumns is None: 723 sortcolumns = region_sortcolumns 724 725 # Define the final sort criteria. 726 727 sort_columns = get_sort_columns(sortcolumns) 728 data_start = int(getQualifiedParameter(request, table_name, "headers") or attrs.get("headers", "1")) 729 730 # Update the column types from the final sort criteria. 731 732 column_types.update(get_column_types(sort_columns)) 733 734 # Sort the rows according to the values in each of the specified columns. 735 736 if sort_columns: 737 headers = table[:data_start] 738 data = table[data_start:] 739 740 # Perform the sort and reconstruct the table. 741 742 sorter = Sorter(sort_columns, request) 743 data.sort(cmp=sorter) 744 table = headers + data 745 746 # Otherwise, indicate that no sorting is being performed. 747 748 else: 749 sort_columns = None 750 751 # Write the table. 752 753 write = request.write 754 write(fmt.table(1, table_attrs)) 755 756 for rownumber, (row_attrs, columns) in enumerate(table): 757 write(fmt.table_row(1, row_attrs)) 758 sortable_heading = sort_columns is not None and rownumber == data_start - 1 759 760 for columnnumber, (column_attrs, column_text) in enumerate(columns): 761 762 # Always skip column continuation cells. 763 764 if column_attrs.get("colcontinuation"): 765 continue 766 767 # Where sorting has not occurred, preserve rowspans and do not write 768 # cells that continue a rowspan. 769 770 if not sort_columns: 771 if column_attrs.get("rowcontinuation"): 772 continue 773 774 # Where sorting has occurred, replicate cell contents and remove any 775 # rowspans. 776 777 else: 778 if column_attrs.has_key("rowspan"): 779 del column_attrs["rowspan"] 780 781 # Remove any continuation attributes that still apply. 782 783 if column_attrs.has_key("rowcontinuation"): 784 del column_attrs["rowcontinuation"] 785 786 write(fmt.table_cell(1, column_attrs)) 787 788 if sortable_heading: 789 write(fmt.div(1, css_class="sortablecolumn")) 790 791 write(formatText(column_text or "", request, fmt)) 792 793 # Add sorting controls, if appropriate. 794 795 if sortable_heading: 796 write_sort_control(request, columnnumber, columns, sort_columns, column_types, table_name) 797 write(fmt.div(0)) 798 799 write(fmt.table_cell(0)) 800 801 write(fmt.table_row(0)) 802 803 write(fmt.table(0)) 804 805 # vim: tabstop=4 expandtab shiftwidth=4