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 formatText(text, request, fmt): 255 256 "Format the given 'text' using the specified 'request' and formatter 'fmt'." 257 258 parser_cls = getParserClass(request, request.page.pi["format"]) 259 parser = parser_cls(text, request, line_anchors=False) 260 return request.redirectedOutput(parser.format, fmt, inhibit_p=True) 261 262 # Sorting utilities. 263 264 def get_sort_columns(s, start=0): 265 266 """ 267 Split the comma-separated string 's', extracting the column specifications 268 of the form <column>["n"] where the prefix "n" indicates an optional 269 numeric conversion for that column. Column indexes start from the specified 270 'start' value (defaulting to 0). 271 """ 272 273 sort_columns = [] 274 for column_spec in s.split(","): 275 column_spec = column_spec.strip() 276 277 ascending = True 278 if column_spec.endswith("d"): 279 column_spec = column_spec[:-1] 280 ascending = False 281 282 # Extract the conversion indicator and column index. 283 # Ignore badly-specified columns. 284 285 try: 286 column = get_number(column_spec) 287 suffix = column_spec[len(column):] 288 fn = converters[suffix] 289 sort_columns.append((max(0, int(column) - start), fn, ascending)) 290 except ValueError: 291 pass 292 293 return sort_columns 294 295 def get_column_types(sort_columns): 296 297 """ 298 Return a dictionary mapping column indexes to conversion functions. 299 """ 300 301 d = {} 302 for column, fn, ascending in sort_columns: 303 d[column] = fn, ascending 304 return d 305 306 def get_number(s): 307 308 "From 's', get any leading number." 309 310 match = leading_number_regexp.match(s) 311 if match: 312 return match.group() 313 else: 314 return "" 315 316 def to_number(s): 317 318 "Convert 's' to a number, discarding any non-numeric trailing data." 319 320 return int(get_number(s)) 321 322 class Sorter: 323 324 "A sorting helper class." 325 326 def __init__(self, sort_columns): 327 self.sort_columns = sort_columns 328 329 def __call__(self, row1, row2): 330 row_attrs1, columns1 = row1 331 row_attrs2, columns2 = row2 332 333 # Apply the conversions to each column, comparing the results. 334 335 for column, fn, ascending in self.sort_columns: 336 column_attrs1, text1 = columns1[column] 337 column_attrs2, text2 = columns2[column] 338 339 # Ignore a column when a conversion is not possible. 340 341 try: 342 text1 = fn(text1) 343 text2 = fn(text2) 344 result = cmp(text1, text2) 345 346 # Where the columns differ, return a result observing the sense 347 # (ascending or descending) of the comparison for the column. 348 349 if result != 0: 350 return ascending and result or -result 351 352 except ValueError: 353 pass 354 355 return 0 356 357 def write_sort_control(request, columnnumber, fmt, write, sort_columns, column_types, columns, table_name, start=0): 358 359 """ 360 Write a sort control in a pop-up element which provides a list of links 361 corresponding to modified sort criteria. 362 """ 363 364 _ = request.getText 365 366 write(fmt.div(1, css_class="sortcolumns")) 367 368 # Start with the existing criteria without this column being involved. 369 370 revised_sort_columns = [(column, fn, ascending) 371 for (column, fn, ascending) in sort_columns if column != columnnumber] 372 373 # Get the specification of this column. 374 375 columnfn, columnascending = column_types.get(columnnumber, (str, True)) 376 newsortcolumn = columnnumber, columnfn, columnascending 377 newlabel = columns[columnnumber][1].strip() 378 379 # Show this column in all possible places in the sorting criteria. 380 381 write(fmt.number_list(1)) 382 383 already_have_this_column = len(sort_columns) != len(revised_sort_columns) 384 just_had_this_column = False 385 386 for i, (column, fn, ascending) in enumerate(sort_columns): 387 new_sort_columns = revised_sort_columns[:] 388 new_sort_columns.insert(i, newsortcolumn) 389 label = columns[column][1].strip() 390 391 # Pop-up element showing the column inserted before the sort column. 392 393 if column != columnnumber and not just_had_this_column: 394 write(fmt.listitem(1, css_class="sortcolumn")) 395 396 write(fmt.span(1, css_class="sortcolumn-container")) 397 write(fmt.span(1, css_class="newsortcolumn")) 398 write(formatText(newlabel, request, fmt)) 399 write(fmt.span(0)) 400 write(fmt.span(0)) 401 402 # Link for selection of the modified sort criteria. 403 404 write(fmt.url(1, "?%s#%s" % ( 405 wikiutil.makeQueryString("%s-sortcolumns=%s" % (table_name, get_sort_column_output(new_sort_columns))), 406 fmt.qualify_id(fmt.sanitize_to_id(table_name)) 407 ))) 408 write(formatText(label, request, fmt)) 409 write(fmt.url(0)) 410 411 else: 412 write(fmt.listitem(1)) 413 414 # Either show the column without a link, since the column to be 415 # inserted is already before the current column. 416 417 if just_had_this_column: 418 just_had_this_column = False 419 write(fmt.span(1, css_class="unlinkedcolumn")) 420 write(formatText(label, request, fmt)) 421 write(fmt.span(0)) 422 423 # Or show the column with a link for its removal. 424 425 else: 426 just_had_this_column = True 427 write(fmt.url(1, "?%s#%s" % ( 428 wikiutil.makeQueryString("%s-sortcolumns=%s" % (table_name, get_sort_column_output(revised_sort_columns))), 429 fmt.qualify_id(fmt.sanitize_to_id(table_name)) 430 ), 431 css_class="removecolumn")) 432 write(formatText(label, request, fmt)) 433 write(fmt.url(0)) 434 435 write(fmt.listitem(0)) 436 437 if not already_have_this_column: 438 439 # Write the sorting criteria with this column at the end. 440 441 new_sort_columns = revised_sort_columns[:] 442 new_sort_columns.append(newsortcolumn) 443 444 write(fmt.listitem(1, css_class="sortcolumn", style="list-style-type: none")) 445 446 # Pop-up element showing the column inserted before the sort column. 447 448 write(fmt.span(1, css_class="sortcolumn-container")) 449 write(fmt.span(1, css_class="newsortcolumn")) 450 write(formatText(newlabel, request, fmt)) 451 write(fmt.span(0)) 452 write(fmt.span(0)) 453 454 write(fmt.url(1, "?%s#%s" % ( 455 wikiutil.makeQueryString("%s-sortcolumns=%s" % (table_name, get_sort_column_output(new_sort_columns))), 456 fmt.qualify_id(fmt.sanitize_to_id(table_name)) 457 ))) 458 write(fmt.text(_("..."))) 459 write(fmt.url(0)) 460 461 write(fmt.listitem(0)) 462 463 write(fmt.number_list(0)) 464 465 write(fmt.div(0)) 466 467 def get_sort_column_output(columns, start=0): 468 469 "Return the output criteria for the given 'columns' indexed from 'start'." 470 471 return ",".join([("%d%s%s" % (column + start, suffixes[fn], not ascending and "d" or "")) 472 for (column, fn, ascending) in columns]) 473 474 # Sorting-related tables. 475 476 converters = { 477 "n" : to_number, 478 "" : str, 479 } 480 481 suffixes = {} 482 for key, value in converters.items(): 483 suffixes[value] = key 484 485 # Common formatting functions. 486 487 def formatTable(text, request, fmt, attrs=None): 488 489 """ 490 Format the given 'text' using the specified 'request' and formatter 'fmt'. 491 The optional 'attrs' can be used to control the presentation of the table. 492 """ 493 494 # Parse the table region. 495 496 table_attrs, table = parse(text) 497 498 # Override any region arguments with request parameters. 499 500 table_name = attrs.get("name") 501 table_attrs["tableid"] = table_attrs.get("tableid", table_name) 502 503 # Get sorting criteria from the region and the request. 504 505 region_sortcolumns = attrs.get("sortcolumns") 506 sortcolumns = table_name and getQualifiedParameter(request, table_name, "sortcolumns") or region_sortcolumns 507 508 # Sort the rows according to the values in each of the specified columns. 509 510 data_start = int(table_name and getQualifiedParameter(request, table_name, "headers") or attrs.get("headers", "1")) 511 512 if sortcolumns: 513 headers = table[:data_start] 514 data = table[data_start:] 515 516 # Get the sort columns using Unix sort-like notation. 517 518 sort_columns = get_sort_columns(sortcolumns) 519 region_sort_columns = get_sort_columns(region_sortcolumns) 520 521 sorter = Sorter(sort_columns) 522 data.sort(cmp=sorter) 523 524 table = headers + data 525 column_types = get_column_types(region_sort_columns) 526 527 # Write the table. 528 529 write = request.write 530 write(fmt.table(1, table_attrs)) 531 532 for rownumber, (row_attrs, columns) in enumerate(table): 533 write(fmt.table_row(1, row_attrs)) 534 sortable = sortcolumns and rownumber == data_start - 1 535 536 for columnnumber, (column_attrs, column_text) in enumerate(columns): 537 write(fmt.table_cell(1, column_attrs)) 538 539 if sortable: 540 write(fmt.div(1, css_class="sortablecolumn")) 541 542 write(formatText(column_text, request, fmt)) 543 544 # Add sorting controls, if appropriate. 545 546 if sortable: 547 write_sort_control(request, columnnumber, fmt, write, sort_columns, column_types, columns, table_name) 548 write(fmt.div(0)) 549 550 write(fmt.table_cell(0)) 551 552 write(fmt.table_row(0)) 553 554 write(fmt.table(0)) 555 556 # vim: tabstop=4 expandtab shiftwidth=4