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