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