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