1 """
2 MoinMoin - SortBy Macro
3 Copyright (c) 2001 by Fred Bremmer <Fred.Bremmer @ ubc.ca>
4
5 Based on an earlier SortBy Macro
6 Copyright (c) 2001 by Tim Bird <tbird@lineo.com>
7 All rights reserved, see COPYING for details.
8
9 DESCRIPTION: Sort a table by one or more columns and sort types/orders
10
11 SortBy(page,num_headers,column,sort_type[,othercolumn,sort_type]...)
12
13 Examples:
14
15 [[SortBy(TablePage,1,3,alpha)]]
16
17 This would return the first table on TablePage sorted by column 3
18 (column 1 is the leftmost column). The rows would be sorted using a
19 lexicographic (alpha) sort. It would preserve 1 header row of the
20 table, without sorting it.
21
22 [[SortBy(TablePage,0,2,reversenumber,1,alpha)]]
23
24 This would return the first table on TablePage sorted using a reverse
25 numeric sort on column 2, but rows with identical values in column 2
26 would be sorted in alpha order of their values in column 1. It would
27 not preserve the first row of the table. The first row of the table
28 would be sorted also (zero header rows left unsorted).
29
30 Supported sort types are: alpha, number, nocase, nosort, reversealpha,
31 reversenumber, reversenocase, reversenosort
32
33 'nosort' can be used to make the SortBy macro behave like an Include
34 that only loads the first table from the page, not the whole page.
35 Thus you can have comments on the table page that are not shown in the
36 SortBy rendering on the including page.
37
38 See SortByTest below for a more comprehensive list of examples.
39
40 Utility functions:
41 error() - convert an error message to HTML
42 read_table() - read a file and return its first table's data
43
44 Functions to convert a string into an appropriate sortable value:
45 strip_fmt() - remove wiki formatting and commas
46 to_number() - convert to a number if possible
47 to_nocase() - convert everything to the same case
48 to_nosort() - return ascending numbers regardless of arg
49
50 Main loop functions:
51 process_args() - parse args string and do some error checking
52 sort_table() - do the actual sorting of table rows
53 format() - generate the HTML output
54 execute() - the macro's main loop, called by MoinMoin
55
56 """
57
58
59
60
61 SortByTest = """
62 [[TableOfContents]]
63
64 == Original Table ==
65 ||'''First''' ||'''Second''' ||'''Third''' ||
66 ||A ||2,000,000 ||zebra ||
67 ||C ||1 ||123 ||
68 ||b ||2 ||word ||
69 ||d ||30 ||{{{word}}} ||
70 ||A ||2 ||Wordy ||
71 ||B ||4.2 ||23 ||
72 ||A ||2 ||'''word''' ||
73
74 == Column 1 No Sort ==
75 {{{[[SortBy(SortByTest, 1, 1, nosort)]]}}}
76
77 [[SortBy(SortByTest, 1, 1, nosort)]]
78
79 This simply includes the first table on the source page at this point in the
80 page containing the macro. Rows are in the same order as the original table.
81
82 == Column 1 Reverse No Sort ==
83 {{{[[SortBy(SortByTest, 1, 1, reversenosort)]]}}}
84
85 [[SortBy(SortByTest, 1, 1, reversenosort)]]
86
87 Similar to the previous example, but now all rows except the one header row
88 are in reverse order.
89
90 == Column 1 Alphabetical ==
91 {{{[[SortBy(SortByTest, 1, 1, alpha)]]}}}
92
93 [[SortBy(SortByTest, 1, 1, alpha)]]
94
95 In alphabetical order all uppercase letters are sorted before lowercase
96 letters.
97
98 == Column 1 Reverse Alphabetical ==
99 {{{[[SortBy(SortByTest, 1, 1, reversealpha)]]}}}
100
101 [[SortBy(SortByTest, 1, 1, reversealpha)]]
102
103 Prepending 'reverse' to the sort-type changes the order from ascending to
104 descending.
105
106 == Column 1 Case-Insensitive ==
107 {{{[[SortBy(SortByTest, 1, 1, nocase)]]}}}
108
109 [[SortBy(SortByTest, 1, 1, nocase)]]
110
111 Now 'B' and 'b' are considered equal, and are presented in the order they
112 appear in the original table.
113
114 == Column 2 Numerical ==
115 {{{[[SortBy(SortByTest, 1, 2, number)]]}}}
116
117 [[SortBy(SortByTest, 1, 2, number)]]
118
119 As you would expect, 30 comes before 2,000,000. Commas are ignored for
120 sorting. Integers and floating point can both be used, and they sort
121 correctly.
122
123 == Column 2 Alphabetical ==
124 {{{[[SortBy(SortByTest, 1, 2, alpha)]]}}}
125
126 [[SortBy(SortByTest, 1, 2, alpha)]]
127
128 In 'alphabetical' order, 2 comes before 3, so 2,000,000 sorts before 30.
129
130 == Column 2 Reverse Numerical ==
131 {{{[[SortBy(SortByTest, 1, 2, reversenumber)]]}}}
132
133 [[SortBy(SortByTest, 1, 2, reversenumber)]]
134
135 Again, prepending 'reverse' to the sort-type changes the order from ascending
136 to descending.
137
138 == Column 3 Alphabetical ==
139 {{{[[SortBy(SortByTest, 1, 3, alpha)]]}}}
140
141 [[SortBy(SortByTest, 1, 3, alpha)]]
142
143 Wiki formatting is ignored, and the sort is stable. The variations of 'word'
144 are simply listed in the same order as they appear in the original table.
145
146 == Column 3 Numerical ==
147 {{{[[SortBy(SortByTest, 1, 3, number)]]}}}
148
149 [[SortBy(SortByTest, 1, 3, number)]]
150
151 Now 23 comes before 123, and non-numeric values are sorted in alphabetical
152 order as above.
153
154 == Column 1 Alphabetical, Column 2 Numerical, Column 3 Alphabetical ==
155 {{{[[SortBy(SortByTest, 1, 1, alpha, 2, number, 3, alpha)]]}}}
156
157 [[SortBy(SortByTest, 1, 1, alpha, 2, number, 3, alpha)]]
158
159 The main sort is on column 1, but within the duplicate 'A's the 2's and
160 2,000,000 are sorted, and within the 2's the values in column 3 are in
161 case-sensitive alphabetical order.
162
163 == Column 1 Case-Insensitive, Column 2 Numerical, Column 3 Case-Insensitive ==
164 {{{[[SortBy(SortByTest, 1, 1, nocase, 2, number, 3, nocase)]]}}}
165
166 [[SortBy(SortByTest, 1, 1, nocase, 2, number, 3, nocase)]]
167
168 The main sort is still on column 1, but within the case-insensitive duplicate
169 'b's the values in column 2 are in numerical order. Also, within the duplicate
170 A-2 rows, column 3 is case-insensitive, so now Wordy follows word.
171
172 == Zero Header Rows, Case-Insensitive Sort on Column 1 ==
173 {{{[[SortBy(SortByTest, 0, 1, nocase)]]}}}
174
175 [[SortBy(SortByTest, 0, 1, nocase)]]
176
177 The first row is sorted along with all the other rows.
178
179 == Three Header Rows, Case-Insensitive Sort on Column 1 ==
180 {{{[[SortBy(SortByTest, 3, 1, nocase)]]}}}
181
182 [[SortBy(SortByTest, 3, 1, nocase)]]
183
184 The first three rows are not sorted along with all the other rows.
185
186 == Invalid Argument Examples ==
187
188 [[SortBy(NonExistentPage, 1, 1, nocase)]]
189
190 [[SortBy(SortByTest, 1, 1)]]
191
192 [[SortBy(SortByTest, x, 1, alpha)]]
193
194 [[SortBy(SortByTest, 1, x, alpha)]]
195
196 [[SortBy(SortByTest, -1, 1, alpha)]]
197
198 [[SortBy(SortByTest, 10, 1, alpha)]]
199
200 [[SortBy(SortByTest, 1, 0, alpha)]]
201
202 [[SortBy(SortByTest, 1, 10, alpha)]]
203
204 [[SortBy(SortByTest, 1, 1, x)]]
205
206 [[SortBy(SortByTest, 1, 1, alpha, 1)]]
207
208 """
209
210 import sys, re, StringIO, cgi
211 from MoinMoin.Page import Page
212
213
214 class SortByError(Exception):
215 """Raised anywhere in the macro, caught outside the main loop."""
216
217 def __init__(self, msg=''):
218 """msg -- a string to be displayed to the user"""
219 self.msg = msg
220
221
222 def error(msg, args):
223 """Return a message followed by usage information, all in HTML.
224
225 msg -- a string describing the error
226 args -- the macro's original argument string
227
228 """
229 html = """
230 <tt class="wiki">[[SortBy(%s)]]</tt><br>
231 <strong class="error">SortBy: %s</strong><br>
232 <small>%s<br>
233 Valid sort types are: %s</small>"""
234 msg = cgi.escape(msg)
235 usage = cgi.escape("""Usage: SortBy(TablePage, <number of header rows>,
236 <primary sort column number>, <sort type>
237 [, <secondary sort column>, <sort type>] ... )""")
238 sorts = ' '.join(valid_sorts)
239 return html % (args, msg, usage, sorts)
240
241
242 def read_table(page_file):
243 """Read page_file and convert its first table's lines into row data"""
244 file = open(page_file)
245 intable = 0
246 table_rows = []
247 for line in file.xreadlines():
248 if len(line) < 2 or line[0:2] != '||':
249 if intable: break
250 else: continue
251 else:
252 intable = 1
253 table_rows.append(line[:-1])
254 return table_rows
255
256
257 def strip_fmt(arg):
258 """Remove formatting characters and leading/trailing whitespace.
259
260 Commas (such as in 1,000,000) are considered formatting chars."""
261 for fmt_string in ["'''", "''", '{{{', '}}}', ',']:
262 arg = arg.replace(fmt_string, '')
263 return arg.strip()
264
265
266 def to_number(arg):
267 """Convert arg to int or float if possible, else return a string."""
268 arg = strip_fmt(arg)
269 try: return int(arg)
270 except ValueError:
271 try: return float(arg)
272 except ValueError: return arg
273
274
275 def to_nocase(arg):
276 """Return arg in lowercase with no formatting characters."""
277 return strip_fmt(arg).lower()
278
279
280 def to_nosort(arg, count=[0]):
281 """Return a higher integer each time so rows don't move when sorted."""
282 count[0] += 1
283 return count[0]
284
285
286 decorate_functions = {'alpha': strip_fmt,
287 'number': to_number,
288 'nocase': to_nocase,
289 'nosort': to_nosort}
290 valid_sorts = decorate_functions.keys()
291 valid_sorts.sort()
292 valid_sorts.extend(['reverse'+sort_name for sort_name in valid_sorts])
293
294
295 def process_args(args):
296 """Parse args string, return (sort_page, table, num_headers, sort_list)."""
297 arglist = re.split(r'\s*,\s*', args.strip())
298 if len(arglist) < 4:
299 msg = """Not enough arguments (%s).
300 At least 4 are required.""" % len(arglist)
301 raise SortByError, msg
302 table_page, num_headers = arglist[0:2]
303 try: num_headers = int(num_headers)
304 except ValueError:
305 msg = """Number of header rows (%s)
306 must be an integer""" % num_headers
307 raise SortByError, msg
308 if num_headers < 0:
309 msg = """Number of header rows (%s)
310 must be a positive integer""" % num_headers
311 raise SortByError, msg
312 arglist[:2] = []
313 if len(arglist)%2 != 0:
314 raise SortByError, 'Odd number of arguments (%s)' % (len(arglist)+2)
315 sort_list = []
316 while arglist:
317
318
319
320
321
322 sort_type = arglist.pop().lower()
323 sort_column = arglist.pop()
324 try: sort_column = int(sort_column)
325 except ValueError:
326 msg = 'Column number (%s) must be an integer' % sort_column
327 raise SortByError, msg
328 if sort_column < 1:
329 msg = """Column number (%s) must be 1 or higher.
330 1 = leftmost column""" % sort_column
331 raise SortByError, msg
332 sort_column -= 1
333 if sort_type[:7] == 'reverse':
334 reverse = 1
335 sort_type = sort_type[7:]
336 else: reverse = 0
337 sort_list.append((sort_column, sort_type, reverse))
338 sort_page = Page(table_page)
339 try: table_rows = read_table(sort_page._text_filename())
340 except IOError:
341 raise SortByError, 'Unable to open the table page "%s"' % table_page
342 table = [row.split('||')[1:-1] for row in table_rows]
343 if num_headers > len(table):
344 msg = """Number of header rows (%s) is more than
345 the table length (%s)""" % (num_headers, len(table))
346 raise SortByError, msg
347 return (sort_page, table, num_headers, sort_list)
348
349
350 def sort_table(table, num_headers, sort_column, sort_type, reverse):
351 """Sort of the table (in-place), preserving num_headers rows at the top.
352
353 Arguments:
354 table -- a list of lists representing rows of column entries
355 num_headers -- an integer number of rows to keep at the top of the table
356 sort_column -- the column number (zero-based) containing the sort values
357 sort_type -- which kind of sort to perform on the values being sorted
358 reverse -- 0 or 1, meaning an ascending or descending (reverse) sort
359
360 """
361 header = table[:num_headers]
362 table[:num_headers] = []
363
364 if table and sort_column > min([len(row) for row in table]):
365 msg = """Column number (%s) is higher than
366 the length of one or more rows""" % (sort_column+1)
367 raise SortByError, msg
368 if sort_type not in valid_sorts:
369 raise SortByError, 'Invalid sort type "%s"' % sort_type
370 decorate = decorate_functions[sort_type]
371
372
373
374
375 decorations = [decorate(row[sort_column]) for row in table]
376 if reverse: indices = xrange(len(table), 0, -1)
377 else: indices = xrange(len(table))
378 decorated = zip(decorations, indices, table)
379 decorated.sort()
380 table[:] = [row for (decoration, index, row) in decorated]
381
382 if reverse: table.reverse()
383 table[:0] = header
384 return
385
386
387 def format(sort_page, macro, table):
388 """Format the sorted table and return it as an HTML fragment.
389
390 Arguments:
391 sort_page -- a MoinMoin Page object representing the table page
392 macro -- the macro argument that was provided by MoinMoin
393 table -- a list of lists representing rows of column entries
394
395 """
396 ret = ''
397 table_rows = ['||%s||' % '||'.join(row) for row in table]
398 sort_page.set_raw_body('\n'.join(table_rows))
399
400
401 stdout = sys.stdout
402 sys.stdout = StringIO.StringIO()
403 sort_page.send_page(macro.form, content_only=1)
404 ret += sys.stdout.getvalue()
405 sys.stdout = stdout
406
407
408 name = sort_page.page_name
409 ret += macro.formatter.url(name, '<small>[goto %s]</small>'%name)
410 return ret
411
412
413 def execute(macro, args):
414 """Parse args, sort_table() repeatedly, return formatted table as HTML.
415
416 Sort the table once for each column & sort_type in the sort_list
417 beginning with the last (and least significant) sort in args
418 because they were 'popped' from the end of the args. Since
419 sort_table() uses a stable sort algorithm, the ordering of the
420 earlier sorts is preserved in the later sorts when there are
421 duplicates of the sort key.
422
423 """
424 try:
425 sort_page, table, num_headers, sort_list = process_args(args)
426 for sort_column, sort_type, reverse in sort_list:
427 sort_table(table, num_headers, sort_column, sort_type, reverse)
428 return format(sort_page, macro, table)
429 except SortByError, e: return error(e.msg, args)