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 < 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 220 return table_attrs, rows 221 222 def span_columns(columns, columnnumber): 223 224 """ 225 In the 'columns', make the column with the 'columnnumber' span the specified 226 number of columns, returning the next appropriate column number. 227 """ 228 229 column_attrs, text = columns[columnnumber] 230 231 # Handle any previous column spanning other columns. 232 233 if column_attrs.has_key("colspan"): 234 colspan = int(strip_token(column_attrs["colspan"])) 235 236 # Duplicate the current column as continuation 237 # columns for as long as the colspan is defined. 238 239 colspan -= 1 240 while colspan > 0: 241 attrs = {} 242 attrs.update(column_attrs) 243 attrs["colspan"] = str(colspan) 244 attrs["colcontinuation"] = True 245 246 if columnnumber != -1: 247 columnnumber += 1 248 if columnnumber < len(columns): 249 columns[columnnumber] = attrs, text 250 else: 251 columnnumber = -1 252 253 if columnnumber == -1: 254 columns.append((attrs, text)) 255 256 colspan -= 1 257 258 return columnnumber 259 260 def extractAttributes(columns, row_attrs, table_attrs): 261 262 """ 263 Extract row- and table-level attributes from 'columns', storing them in 264 'row_attrs' and 'table_attrs' respectively. 265 """ 266 267 for column in columns: 268 attrs = column[0] 269 for name, value in attrs.items(): 270 if name.startswith("row") and name not in ("rowspan", "rowcontinuation"): 271 row_attrs[name] = value 272 del attrs[name] 273 elif name.startswith("table"): 274 table_attrs[name] = value 275 del attrs[name] 276 277 # Sorting utilities. 278 279 def get_sort_columns(s, start=0): 280 281 """ 282 Split the comma-separated string 's', extracting the column specifications 283 of the form <column>["n"] where the suffix "n" indicates an optional 284 numeric conversion for that column. Column indexes start from the specified 285 'start' value (defaulting to 0). 286 """ 287 288 sort_columns = [] 289 for column_spec in s.split(","): 290 column_spec = column_spec.strip() 291 292 ascending = True 293 if column_spec.endswith("d"): 294 column_spec = column_spec[:-1] 295 ascending = False 296 297 # Extract the conversion indicator and column index. 298 # Ignore badly-specified columns. 299 300 try: 301 column = get_number(column_spec) 302 suffix = column_spec[len(column):] 303 fn = converters[suffix] 304 sort_columns.append((max(0, int(column) - start), fn, ascending)) 305 except ValueError: 306 pass 307 308 return sort_columns 309 310 def get_column_types(sort_columns): 311 312 """ 313 Return a dictionary mapping column indexes to conversion functions. 314 """ 315 316 d = {} 317 for column, fn, ascending in sort_columns: 318 d[column] = fn, ascending 319 return d 320 321 def get_number(s): 322 323 "From 's', get any leading number." 324 325 match = leading_number_regexp.match(s) 326 if match: 327 return match.group() 328 else: 329 return "" 330 331 def to_number(s, request): 332 333 """ 334 Convert 's' to a number, discarding any non-numeric trailing data. 335 Return an empty string if 's' is empty. 336 """ 337 338 if s: 339 return int(get_number(to_plain_text(s, request))) 340 else: 341 return s 342 343 def to_plain_text(s, request): 344 345 "Convert 's' to plain text." 346 347 fmt = getFormatterClass(request, "plain")(request) 348 fmt.setPage(request.page) 349 return formatText(s, request, fmt) 350 351 converters = { 352 "n" : to_number, 353 "" : to_plain_text, 354 } 355 356 suffixes = {} 357 for key, value in converters.items(): 358 suffixes[value] = key 359 360 class Sorter: 361 362 "A sorting helper class." 363 364 def __init__(self, sort_columns, request): 365 self.sort_columns = sort_columns 366 self.request = request 367 368 def __call__(self, row1, row2): 369 row_attrs1, columns1 = row1 370 row_attrs2, columns2 = row2 371 372 # Apply the conversions to each column, comparing the results. 373 374 for column, fn, ascending in self.sort_columns: 375 column_attrs1, text1 = columns1[column] 376 column_attrs2, text2 = columns2[column] 377 378 # Ignore a column when a conversion is not possible. 379 380 try: 381 value1 = fn(text1, self.request) 382 value2 = fn(text2, self.request) 383 384 # Avoid empty strings appearing earlier than other values. 385 386 if value1 == "" and value2 != "": 387 result = 1 388 elif value1 != "" and value2 == "": 389 result = -1 390 else: 391 result = cmp(value1, value2) 392 393 # Where the columns differ, return a result observing the sense 394 # (ascending or descending) of the comparison for the column. 395 396 if result != 0: 397 return ascending and result or -result 398 399 except ValueError: 400 pass 401 402 return 0 403 404 def write_sort_control(request, columnnumber, columns, sort_columns, column_types, table_name, start=0, write=None): 405 406 """ 407 Using the 'request', write a sort control for the given 'columnnumber' in 408 the collection of 'columns', using the existing 'sort_columns' and 409 'column_types' to construct labels and links that modify the sort criteria, 410 and using the given 'table_name' to parameterise the links. 411 412 If the 'write' parameter is specified, use it to write output; otherwise, 413 write output using the request. 414 """ 415 416 fmt = request.formatter 417 write = write or request.write 418 _ = request.getText 419 420 write(fmt.div(1, css_class="sortcolumns")) 421 422 write(fmt.paragraph(1)) 423 write(fmt.text(_("Sort by columns..."))) 424 write(fmt.paragraph(0)) 425 426 # Start with the existing criteria without this column being involved. 427 428 revised_sort_columns = [(column, fn, ascending) 429 for (column, fn, ascending) in sort_columns if column != columnnumber] 430 431 # Get the specification of this column. 432 433 columnfn, columnascending = column_types.get(columnnumber, (to_plain_text, True)) 434 newsortcolumn = columnnumber, columnfn, columnascending 435 newsortcolumn_reverse = columnnumber, columnfn, not columnascending 436 newlabel = columns[columnnumber][1].strip() 437 438 # Show this column in all possible places in the sorting criteria. 439 440 write(fmt.number_list(1)) 441 442 just_had_this_column = False 443 444 for i, (column, fn, ascending) in enumerate(sort_columns): 445 new_sort_columns = revised_sort_columns[:] 446 new_sort_columns.insert(i, newsortcolumn) 447 label = columns[column][1].strip() 448 449 arrow = columnascending and down_arrow or up_arrow 450 arrow_reverse = not columnascending and down_arrow or up_arrow 451 452 sortcolumns = get_sort_column_output(new_sort_columns) 453 new_sort_columns[i] = newsortcolumn_reverse 454 sortcolumns_reverse = get_sort_column_output(new_sort_columns) 455 456 # Columns permitting the insertion of the selected column. 457 458 if column != columnnumber and not just_had_this_column: 459 write(fmt.listitem(1, css_class="sortcolumn")) 460 461 # Pop-up element showing the column inserted before the sort column. 462 463 write(fmt.span(1, css_class="sortcolumn-container")) 464 write(fmt.span(1, css_class="newsortcolumn")) 465 write(formatText(newlabel, request, fmt)) 466 467 write_sort_link(write, request, fmt, table_name, sortcolumns, arrow, "sortdirection") 468 write_sort_link(write, request, fmt, table_name, sortcolumns_reverse, arrow_reverse, "sortdirection") 469 470 write(fmt.span(0)) 471 write(fmt.span(0)) 472 473 # Link for selection of the modified sort criteria using the current 474 # column and showing its particular direction. 475 476 arrow = ascending and down_arrow or up_arrow 477 arrow_reverse = not ascending and down_arrow or up_arrow 478 write_sort_link(write, request, fmt, table_name, sortcolumns, u"%s %s" % (label, arrow), "") 479 480 # Columns permitting removal or modification. 481 482 else: 483 write(fmt.listitem(1)) 484 485 # Either show the column without a link, since the column to be 486 # inserted is already before the current column. 487 488 if just_had_this_column: 489 just_had_this_column = False 490 arrow = ascending and down_arrow or up_arrow 491 arrow_reverse = not ascending and down_arrow or up_arrow 492 493 # Write the current column with its particular direction. 494 495 write(fmt.span(1, css_class="unlinkedcolumn")) 496 write(formatText(u"%s %s" % (label, arrow), request, fmt)) 497 write(fmt.span(0)) 498 499 # Or show the column with a link for its removal. 500 501 else: 502 just_had_this_column = True 503 sortcolumns_revised = get_sort_column_output(revised_sort_columns) 504 write_sort_link(write, request, fmt, table_name, sortcolumns_revised, u"%s %s" % (label, arrow), "removecolumn") 505 506 # Alternative sort direction. 507 508 write_sort_link(write, request, fmt, table_name, sortcolumns_reverse, arrow_reverse, "altdirection") 509 510 write(fmt.listitem(0)) 511 512 if not just_had_this_column: 513 514 # Write the sorting criteria with this column at the end. 515 516 new_sort_columns = revised_sort_columns[:] 517 new_sort_columns.append(newsortcolumn) 518 519 sortcolumns = get_sort_column_output(new_sort_columns) 520 new_sort_columns[-1] = newsortcolumn_reverse 521 sortcolumns_reverse = get_sort_column_output(new_sort_columns) 522 523 arrow = columnascending and down_arrow or up_arrow 524 arrow_reverse = not columnascending and down_arrow or up_arrow 525 526 write(fmt.listitem(1, css_class="appendcolumn")) 527 528 # Pop-up element showing the column inserted before the sort column. 529 530 write(fmt.span(1, css_class="newsortcolumn")) 531 write_sort_link(write, request, fmt, table_name, sortcolumns, newlabel, "") 532 write_sort_link(write, request, fmt, table_name, sortcolumns, arrow, "sortdirection") 533 write_sort_link(write, request, fmt, table_name, sortcolumns_reverse, arrow_reverse, "sortdirection") 534 write(fmt.span(0)) 535 536 write(fmt.listitem(0)) 537 538 write(fmt.number_list(0)) 539 540 write(fmt.div(0)) 541 542 def write_sort_link(write, request, fmt, table_name, sortcolumns, label, css_class): 543 544 "Write a link expressing sort criteria." 545 546 write(fmt.url(1, "?%s#%s" % ( 547 wikiutil.makeQueryString("%s-sortcolumns=%s" % (table_name, sortcolumns)), 548 fmt.qualify_id(fmt.sanitize_to_id(table_name)) 549 ), css_class=css_class)) 550 write(formatText(label, request, fmt)) 551 write(fmt.url(0)) 552 553 def get_sort_column_output(columns, start=0): 554 555 "Return the output criteria for the given 'columns' indexed from 'start'." 556 557 return ",".join([("%d%s%s" % (column + start, suffixes[fn], not ascending and "d" or "")) 558 for (column, fn, ascending) in columns]) 559 560 # Common formatting functions. 561 562 def formatTable(text, request, fmt, attrs=None, write=None): 563 564 """ 565 Format the given 'text' using the specified 'request' and formatter 'fmt'. 566 The optional 'attrs' can be used to control the presentation of the table. 567 568 If the 'write' parameter is specified, use it to write output; otherwise, 569 write output using the request. 570 """ 571 572 # Parse the table region. 573 574 table_attrs, table = parse(text) 575 576 # Define the table name and an anchor attribute. 577 578 table_name = attrs.get("name") 579 if table_name: 580 table_attrs["tableid"] = table_name 581 else: 582 table_name = table_attrs.get("tableid") 583 584 # Only attempt to offer sorting capabilities if a table name is specified. 585 586 if table_name: 587 588 # Get the underlying column types. 589 590 column_types = get_column_types(get_sort_columns(attrs.get("columntypes", ""))) 591 592 # Get sorting criteria from the region. 593 594 region_sortcolumns = attrs.get("sortcolumns", "") 595 596 # Update the column types from the sort criteria. 597 598 column_types.update(get_column_types(get_sort_columns(region_sortcolumns))) 599 600 # Determine the applicable sort criteria using the request. 601 602 sortcolumns = getQualifiedParameter(request, table_name, "sortcolumns") 603 if sortcolumns is None: 604 sortcolumns = region_sortcolumns 605 606 # Define the final sort criteria. 607 608 sort_columns = get_sort_columns(sortcolumns) 609 data_start = int(getQualifiedParameter(request, table_name, "headers") or attrs.get("headers", "1")) 610 611 # Update the column types from the final sort criteria. 612 613 column_types.update(get_column_types(sort_columns)) 614 615 # Sort the rows according to the values in each of the specified columns. 616 617 if sort_columns: 618 headers = table[:data_start] 619 data = table[data_start:] 620 621 # Perform the sort and reconstruct the table. 622 623 sorter = Sorter(sort_columns, request) 624 data.sort(cmp=sorter) 625 table = headers + data 626 627 # Otherwise, indicate that no sorting is being performed. 628 629 else: 630 sort_columns = None 631 632 # Write the table. 633 634 write = write or request.write 635 write(fmt.table(1, table_attrs)) 636 637 for rownumber, (row_attrs, columns) in enumerate(table): 638 write(fmt.table_row(1, row_attrs)) 639 sortable_heading = sort_columns is not None and rownumber == data_start - 1 640 641 for columnnumber, (column_attrs, column_text) in enumerate(columns): 642 643 # Always skip column continuation cells. 644 645 if column_attrs.get("colcontinuation"): 646 continue 647 648 # Where sorting has not occurred, preserve rowspans and do not write 649 # cells that continue a rowspan. 650 651 if not sort_columns: 652 if column_attrs.get("rowcontinuation"): 653 continue 654 655 # Where sorting has occurred, replicate cell contents and remove any 656 # rowspans. 657 658 else: 659 if column_attrs.has_key("rowspan"): 660 del column_attrs["rowspan"] 661 662 # Remove any continuation attributes that still apply. 663 664 if column_attrs.has_key("rowcontinuation"): 665 del column_attrs["rowcontinuation"] 666 667 write(fmt.table_cell(1, column_attrs)) 668 669 if sortable_heading: 670 write(fmt.div(1, css_class="sortablecolumn")) 671 672 write(formatText(column_text or "", request, fmt)) 673 674 # Add sorting controls, if appropriate. 675 676 if sortable_heading: 677 write_sort_control(request, columnnumber, columns, sort_columns, column_types, table_name, write=write) 678 write(fmt.div(0)) 679 680 write(fmt.table_cell(0)) 681 682 write(fmt.table_row(0)) 683 684 write(fmt.table(0)) 685 686 def formatTableForOutputType(text, request, mimetype, attrs=None, write=None): 687 688 """ 689 Format the given 'text' using the specified 'request' for the given output 690 'mimetype'. 691 692 The optional 'attrs' can be used to control the presentation of the table. 693 694 If the 'write' parameter is specified, use it to write output; otherwise, 695 write output using the request. 696 """ 697 698 write = write or request.write 699 700 if mimetype == "text/html": 701 write('<html>') 702 write('<body>') 703 fmt = request.html_formatter 704 fmt.setPage(request.page) 705 formatTable(text, request, fmt, attrs, write) 706 write('</body>') 707 write('</html>') 708 709 # vim: tabstop=4 expandtab shiftwidth=4