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