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