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