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