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"^\s*(?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 "sections" : (r"(^\s*{{{.*?^\s*}}})", re.MULTILINE | re.DOTALL), # {{{ ... }}} 25 "rows" : (r"^==", re.MULTILINE), # == 26 27 # Within text: 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[0][0], 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[0][0], 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(attrs, row_attrs, table_attrs): 239 240 """ 241 Extract row- and table-level attributes from 'attrs', storing them in 242 'row_attrs' and 'table_attrs' respectively. 243 """ 244 245 for name, value in attrs.items(): 246 if name.startswith("row") and name not in ("rowspan", "rowcontinuation"): 247 row_attrs[name] = value 248 del attrs[name] 249 elif name.startswith("table"): 250 table_attrs[name] = value 251 del attrs[name] 252 253 def replaceMarkers(s): 254 255 "Convert the section notation in 's'." 256 257 l = [] 258 last = 0 259 260 # Get each marker and convert it. 261 262 for match in patterns["markers"].finditer(s): 263 start, stop = match.span() 264 l.append(s[last:start]) 265 266 # Convert the marker. 267 268 marker = [] 269 brace = True 270 for text in patterns["marker"].split(match.group()): 271 if brace: 272 marker.append(text) 273 else: 274 marker.append(text[:-1]) 275 brace = not brace 276 277 l.append("".join(marker)) 278 last = stop 279 else: 280 l.append(s[last:]) 281 282 return "".join(l) 283 284 def parseAttributes(s, escape=True): 285 286 """ 287 Parse the table attributes string 's', returning a mapping of names to 288 values. If 'escape' is set to a true value, the attributes will be suitable 289 for use with the formatter API. If 'escape' is set to a false value, the 290 attributes will have any quoting removed. 291 """ 292 293 attrs = {} 294 f = StringIO(s) 295 name = None 296 need_value = False 297 298 for token in shlex(f): 299 300 # Capture the name if needed. 301 302 if name is None: 303 name = escape and wikiutil.escape(token) or strip_token(token) 304 305 # Detect either an equals sign or another name. 306 307 elif not need_value: 308 if token == "=": 309 need_value = True 310 else: 311 attrs[name.lower()] = escape and "true" or True 312 name = wikiutil.escape(token) 313 314 # Otherwise, capture a value. 315 316 else: 317 # Quoting of attributes done similarly to wikiutil.parseAttributes. 318 319 if token: 320 if escape: 321 if token[0] in ("'", '"'): 322 token = wikiutil.escape(token) 323 else: 324 token = '"%s"' % wikiutil.escape(token, 1) 325 else: 326 token = strip_token(token) 327 328 attrs[name.lower()] = token 329 name = None 330 need_value = False 331 332 return attrs 333 334 def strip_token(token): 335 336 "Return the given 'token' stripped of quoting." 337 338 if token[0] in ("'", '"') and token[-1] == token[0]: 339 return token[1:-1] 340 else: 341 return token 342 343 # Formatting of embedded content. 344 # NOTE: Borrowed from EventAggregator. 345 346 def getParserClass(request, format): 347 348 """ 349 Return a parser class using the 'request' for the given 'format', returning 350 a plain text parser if no parser can be found for the specified 'format'. 351 """ 352 353 try: 354 return wikiutil.searchAndImportPlugin(request.cfg, "parser", format or "plain") 355 except wikiutil.PluginMissingError: 356 return wikiutil.searchAndImportPlugin(request.cfg, "parser", "plain") 357 358 def getFormatterClass(request, format): 359 360 """ 361 Return a formatter class using the 'request' for the given output 'format', 362 returning a plain text formatter if no formatter can be found for the 363 specified 'format'. 364 """ 365 366 try: 367 return wikiutil.searchAndImportPlugin(request.cfg, "formatter", format or "plain") 368 except wikiutil.PluginMissingError: 369 return wikiutil.searchAndImportPlugin(request.cfg, "formatter", "plain") 370 371 def formatText(text, request, fmt): 372 373 "Format the given 'text' using the specified 'request' and formatter 'fmt'." 374 375 parser_cls = getParserClass(request, request.page.pi["format"]) 376 parser = parser_cls(text, request, line_anchors=False) 377 old_fmt = request.formatter 378 request.formatter = fmt 379 try: 380 return redirectedOutput(request, parser, fmt, inhibit_p=True) 381 finally: 382 request.formatter = old_fmt 383 384 def redirectedOutput(request, parser, fmt, **kw): 385 386 "A fixed version of the request method of the same name." 387 388 buf = StringIO() 389 request.redirect(buf) 390 try: 391 parser.format(fmt, **kw) 392 if hasattr(fmt, "flush"): 393 buf.write(fmt.flush(True)) 394 finally: 395 request.redirect() 396 text = buf.getvalue() 397 buf.close() 398 return text 399 400 # Sorting utilities. 401 402 def get_sort_columns(s, start=0): 403 404 """ 405 Split the comma-separated string 's', extracting the column specifications 406 of the form <column>["n"] where the prefix "n" indicates an optional 407 numeric conversion for that column. Column indexes start from the specified 408 'start' value (defaulting to 0). 409 """ 410 411 sort_columns = [] 412 for column_spec in s.split(","): 413 column_spec = column_spec.strip() 414 415 ascending = True 416 if column_spec.endswith("d"): 417 column_spec = column_spec[:-1] 418 ascending = False 419 420 # Extract the conversion indicator and column index. 421 # Ignore badly-specified columns. 422 423 try: 424 column = get_number(column_spec) 425 suffix = column_spec[len(column):] 426 fn = converters[suffix] 427 sort_columns.append((max(0, int(column) - start), fn, ascending)) 428 except ValueError: 429 pass 430 431 return sort_columns 432 433 def get_column_types(sort_columns): 434 435 """ 436 Return a dictionary mapping column indexes to conversion functions. 437 """ 438 439 d = {} 440 for column, fn, ascending in sort_columns: 441 d[column] = fn, ascending 442 return d 443 444 def get_number(s): 445 446 "From 's', get any leading number." 447 448 match = leading_number_regexp.match(s) 449 if match: 450 return match.group() 451 else: 452 return "" 453 454 def to_number(s, request): 455 456 """ 457 Convert 's' to a number, discarding any non-numeric trailing data. 458 Return an empty string if 's' is empty. 459 """ 460 461 if s: 462 return int(get_number(s)) 463 else: 464 return s 465 466 def to_plain_text(s, request): 467 468 "Convert 's' to plain text." 469 470 fmt = getFormatterClass(request, "plain")(request) 471 fmt.setPage(request.page) 472 return formatText(s, request, fmt) 473 474 converters = { 475 "n" : to_number, 476 "" : to_plain_text, 477 } 478 479 suffixes = {} 480 for key, value in converters.items(): 481 suffixes[value] = key 482 483 class Sorter: 484 485 "A sorting helper class." 486 487 def __init__(self, sort_columns, request): 488 self.sort_columns = sort_columns 489 self.request = request 490 491 def __call__(self, row1, row2): 492 row_attrs1, columns1 = row1 493 row_attrs2, columns2 = row2 494 495 # Apply the conversions to each column, comparing the results. 496 497 for column, fn, ascending in self.sort_columns: 498 column_attrs1, text1 = columns1[column] 499 column_attrs2, text2 = columns2[column] 500 501 # Ignore a column when a conversion is not possible. 502 503 try: 504 value1 = fn(text1, self.request) 505 value2 = fn(text2, self.request) 506 507 # Avoid empty strings appearing earlier than other values. 508 509 if value1 == "" and value2 != "": 510 result = 1 511 elif value1 != "" and value2 == "": 512 result = -1 513 else: 514 result = cmp(value1, value2) 515 516 # Where the columns differ, return a result observing the sense 517 # (ascending or descending) of the comparison for the column. 518 519 if result != 0: 520 return ascending and result or -result 521 522 except ValueError: 523 pass 524 525 return 0 526 527 def write_sort_control(request, columnnumber, columns, sort_columns, column_types, table_name, start=0): 528 529 """ 530 Using the 'request', write a sort control for the given 'columnnumber' in 531 the collection of 'columns', using the existing 'sort_columns' and 532 'column_types' to construct labels and links that modify the sort criteria, 533 and using the given 'table_name' to parameterise the links. 534 """ 535 536 fmt = request.formatter 537 write = request.write 538 _ = request.getText 539 540 write(fmt.div(1, css_class="sortcolumns")) 541 542 write(fmt.paragraph(1)) 543 write(fmt.text(_("Sort by columns..."))) 544 write(fmt.paragraph(0)) 545 546 # Start with the existing criteria without this column being involved. 547 548 revised_sort_columns = [(column, fn, ascending) 549 for (column, fn, ascending) in sort_columns if column != columnnumber] 550 551 # Get the specification of this column. 552 553 columnfn, columnascending = column_types.get(columnnumber, (to_plain_text, True)) 554 newsortcolumn = columnnumber, columnfn, columnascending 555 newsortcolumn_reverse = columnnumber, columnfn, not columnascending 556 newlabel = columns[columnnumber][1].strip() 557 558 # Show this column in all possible places in the sorting criteria. 559 560 write(fmt.number_list(1)) 561 562 just_had_this_column = False 563 564 for i, (column, fn, ascending) in enumerate(sort_columns): 565 new_sort_columns = revised_sort_columns[:] 566 new_sort_columns.insert(i, newsortcolumn) 567 label = columns[column][1].strip() 568 569 arrow = columnascending and down_arrow or up_arrow 570 arrow_reverse = not columnascending and down_arrow or up_arrow 571 572 sortcolumns = get_sort_column_output(new_sort_columns) 573 new_sort_columns[i] = newsortcolumn_reverse 574 sortcolumns_reverse = get_sort_column_output(new_sort_columns) 575 576 # Columns permitting the insertion of the selected column. 577 578 if column != columnnumber and not just_had_this_column: 579 write(fmt.listitem(1, css_class="sortcolumn")) 580 581 # Pop-up element showing the column inserted before the sort column. 582 583 write(fmt.span(1, css_class="sortcolumn-container")) 584 write(fmt.span(1, css_class="newsortcolumn")) 585 write(formatText(newlabel, request, fmt)) 586 587 write_sort_link(write, request, fmt, table_name, sortcolumns, arrow, "sortdirection") 588 write_sort_link(write, request, fmt, table_name, sortcolumns_reverse, arrow_reverse, "sortdirection") 589 590 write(fmt.span(0)) 591 write(fmt.span(0)) 592 593 # Link for selection of the modified sort criteria using the current 594 # column and showing its particular direction. 595 596 arrow = ascending and down_arrow or up_arrow 597 arrow_reverse = not ascending and down_arrow or up_arrow 598 write_sort_link(write, request, fmt, table_name, sortcolumns, u"%s %s" % (label, arrow), "") 599 600 # Columns permitting removal or modification. 601 602 else: 603 write(fmt.listitem(1)) 604 605 # Either show the column without a link, since the column to be 606 # inserted is already before the current column. 607 608 if just_had_this_column: 609 just_had_this_column = False 610 arrow = ascending and down_arrow or up_arrow 611 arrow_reverse = not ascending and down_arrow or up_arrow 612 613 # Write the current column with its particular direction. 614 615 write(fmt.span(1, css_class="unlinkedcolumn")) 616 write(formatText(u"%s %s" % (label, arrow), request, fmt)) 617 write(fmt.span(0)) 618 619 # Or show the column with a link for its removal. 620 621 else: 622 just_had_this_column = True 623 sortcolumns_revised = get_sort_column_output(revised_sort_columns) 624 write_sort_link(write, request, fmt, table_name, sortcolumns_revised, u"%s %s" % (label, arrow), "removecolumn") 625 626 # Alternative sort direction. 627 628 write_sort_link(write, request, fmt, table_name, sortcolumns_reverse, arrow_reverse, "altdirection") 629 630 write(fmt.listitem(0)) 631 632 if not just_had_this_column: 633 634 # Write the sorting criteria with this column at the end. 635 636 new_sort_columns = revised_sort_columns[:] 637 new_sort_columns.append(newsortcolumn) 638 639 sortcolumns = get_sort_column_output(new_sort_columns) 640 new_sort_columns[-1] = newsortcolumn_reverse 641 sortcolumns_reverse = get_sort_column_output(new_sort_columns) 642 643 arrow = columnascending and down_arrow or up_arrow 644 arrow_reverse = not columnascending and down_arrow or up_arrow 645 646 write(fmt.listitem(1, css_class="appendcolumn")) 647 648 # Pop-up element showing the column inserted before the sort column. 649 650 write(fmt.span(1, css_class="newsortcolumn")) 651 write_sort_link(write, request, fmt, table_name, sortcolumns, newlabel, "") 652 write_sort_link(write, request, fmt, table_name, sortcolumns, arrow, "sortdirection") 653 write_sort_link(write, request, fmt, table_name, sortcolumns_reverse, arrow_reverse, "sortdirection") 654 write(fmt.span(0)) 655 656 write(fmt.listitem(0)) 657 658 write(fmt.number_list(0)) 659 660 write(fmt.div(0)) 661 662 def write_sort_link(write, request, fmt, table_name, sortcolumns, label, css_class): 663 664 "Write a link expressing sort criteria." 665 666 write(fmt.url(1, "?%s#%s" % ( 667 wikiutil.makeQueryString("%s-sortcolumns=%s" % (table_name, sortcolumns)), 668 fmt.qualify_id(fmt.sanitize_to_id(table_name)) 669 ), css_class=css_class)) 670 write(formatText(label, request, fmt)) 671 write(fmt.url(0)) 672 673 def get_sort_column_output(columns, start=0): 674 675 "Return the output criteria for the given 'columns' indexed from 'start'." 676 677 return ",".join([("%d%s%s" % (column + start, suffixes[fn], not ascending and "d" or "")) 678 for (column, fn, ascending) in columns]) 679 680 # Common formatting functions. 681 682 def formatTable(text, request, fmt, attrs=None): 683 684 """ 685 Format the given 'text' using the specified 'request' and formatter 'fmt'. 686 The optional 'attrs' can be used to control the presentation of the table. 687 """ 688 689 # Parse the table region. 690 691 table_attrs, table = parse(text) 692 693 # Define the table name and an anchor attribute. 694 695 table_name = attrs.get("name") 696 if table_name: 697 table_attrs["tableid"] = table_name 698 else: 699 table_name = table_attrs.get("tableid") 700 701 # Only attempt to offer sorting capabilities if a table name is specified. 702 703 if table_name: 704 705 # Get the underlying column types. 706 707 column_types = get_column_types(get_sort_columns(attrs.get("columntypes", ""))) 708 709 # Get sorting criteria from the region. 710 711 region_sortcolumns = attrs.get("sortcolumns", "") 712 713 # Update the column types from the sort criteria. 714 715 column_types.update(get_column_types(get_sort_columns(region_sortcolumns))) 716 717 # Determine the applicable sort criteria using the request. 718 719 sortcolumns = getQualifiedParameter(request, table_name, "sortcolumns") 720 if sortcolumns is None: 721 sortcolumns = region_sortcolumns 722 723 # Define the final sort criteria. 724 725 sort_columns = get_sort_columns(sortcolumns) 726 data_start = int(getQualifiedParameter(request, table_name, "headers") or attrs.get("headers", "1")) 727 728 # Update the column types from the final sort criteria. 729 730 column_types.update(get_column_types(sort_columns)) 731 732 # Sort the rows according to the values in each of the specified columns. 733 734 if sort_columns: 735 headers = table[:data_start] 736 data = table[data_start:] 737 738 # Perform the sort and reconstruct the table. 739 740 sorter = Sorter(sort_columns, request) 741 data.sort(cmp=sorter) 742 table = headers + data 743 744 # Otherwise, indicate that no sorting is being performed. 745 746 else: 747 sort_columns = None 748 749 # Write the table. 750 751 write = request.write 752 write(fmt.table(1, table_attrs)) 753 754 for rownumber, (row_attrs, columns) in enumerate(table): 755 write(fmt.table_row(1, row_attrs)) 756 sortable_heading = sort_columns is not None and rownumber == data_start - 1 757 758 for columnnumber, (column_attrs, column_text) in enumerate(columns): 759 760 # Always skip column continuation cells. 761 762 if column_attrs.get("colcontinuation"): 763 continue 764 765 # Where sorting has not occurred, preserve rowspans and do not write 766 # cells that continue a rowspan. 767 768 if not sort_columns: 769 if column_attrs.get("rowcontinuation"): 770 continue 771 772 # Where sorting has occurred, replicate cell contents and remove any 773 # rowspans. 774 775 else: 776 if column_attrs.has_key("rowspan"): 777 del column_attrs["rowspan"] 778 779 # Remove any continuation attributes that still apply. 780 781 if column_attrs.has_key("rowcontinuation"): 782 del column_attrs["rowcontinuation"] 783 784 write(fmt.table_cell(1, column_attrs)) 785 786 if sortable_heading: 787 write(fmt.div(1, css_class="sortablecolumn")) 788 789 write(formatText(column_text or "", request, fmt)) 790 791 # Add sorting controls, if appropriate. 792 793 if sortable_heading: 794 write_sort_control(request, columnnumber, columns, sort_columns, column_types, table_name) 795 write(fmt.div(0)) 796 797 write(fmt.table_cell(0)) 798 799 write(fmt.table_row(0)) 800 801 write(fmt.table(0)) 802 803 # vim: tabstop=4 expandtab shiftwidth=4