I wanted to get some understanding of the alternatives available on MercadoLibre for buying a laptop here in Argentina, so I wrote this program to help me slice and dice it.
I wrote the program in March, but I was hoping to clean it up a bit before posting it, since now I know some easy-to-fix usability problems in the UI. The program is downloadable from http://pobox.com/~kragen/sw/laptoptable.py and you can try the DHTML output out at http://pobox.com/~kragen/sw/laptoptable.html --- maybe less than optimally useful. I think it works in Firefox and Safari; it does not work in Konqueror from KDE 3.5.5. Like everything else posted to kragen-hacks without a notice to the contrary, this program is in the public domain. The images (not included in this post) are part of Mozilla and licensed under the relevant Mozilla licenses. Other interesting notes: - contains yet another RFC-822 parser. When am I going to stop writing these? - contains a tiny model of Nevow.stan, but as Daniel Martin pointed out in http://dtm.livejournal.com/33960.html?thread=44968#t44968 it would be better if the contexts in which things were found determined how they were escaped, rather than the things themselves determining it (although the things themselves need to determine whether they are already in HTML or would need to be escaped to become HTML). I'm not sure whether this code does that or not; requires more thought. #!/usr/bin/python # This program gives you a dynamic query view on a small amount of # tabular data --- a page or two. # UI improvements and bugs to fix for current functionality: # - It breaks the Back button. # - Be smarter about composing impossible or useless criteria. If # there are multiple criteria on the same column, the user can # select the wrong one, and changing an "is at least" criterion is # unnecessarily difficult; it probably would be better to unify them # into a single criterion for the column. It's also really easy to # put yourself in a situation where there are no visible rows, which # can make it hard to tell what's going on. Here's what I'm # thinking: # - adding a new value to an "is" criterion: already not possible. # - adding a new value to an "is at least" or "is at most" # criterion: make the criterion into a range criterion, "is # between X and Y". # - adding a new value to an "is between X and Y" criterion: make # it into an "is" criterion, I suppose. If we do enough with the # URL, maybe we could allow undo with the back button. # - clicking on an empty value: I'm not sure what to do in this # case. Maybe change "is" to "is empty or" and the like? # - add a drop-down to add new values to an "is" criterion. # - the above also suggests that you should have a drop-down option # from "between" to "is at least" top of range or "is at most" # bottom. Maybe still display as two separate criteria with # separate drop-downs. # - also make it possible to select most of these criteria from a # popup menu on the table cell. # - Change "is empty" to a kind of relationship. # - which will avoid "is empty or empty". # - also we want "is not empty". # - Use animation for every change. It's hard to tell what's # happening when your screen all changes at once; it's better for # menus to fade in or pop up with animations, rows and columns to # shrink down to nothing or grow up from nothing, perhaps even for # sorting to move the rows up and down. Also, an animation moving # from the clicked point to the descriptive paragraph up top could # help with alerting the user that the paragraph has changed --- or # at least highlight the new element. However, all of this has to # be very quick, sub-100ms, so 5 frames of animation at most. # - Be smarter about composing relationship menus: a single-click on # the relationship name should execute a likely change, to be undone # with another single-click, and if you're in an "empty or" state, # the normal options below should respect that. Likely changes # might be: # - "is" to "is not" or "is empty or" # - "is at least" to "is at most" # - "is between" to "is not between"? # - "is one of" to "is not one of"? # - Also, it may be that menu items should be bigger, especially # those farther off. # - Position the relationship menu correctly; right now the next menu # item creeps up onto the bottom edge of the current selection. # - Adjust the size of the underlying relationship display when the # menu pops up, so that the menu doesn't obscure the quantity being # compared when the relationship is "is". # - Keep the paragraphs at the top and the table headers from # scrolling off the screen. (How does TrimSpreadsheet do it?) # - Better graphics! Check openclipart.org. # - Change sorting to use an algorithm that works on both numbers and # non-numbers, instead of choosing per column. The usual approach # is to split each string into alternating numeric and non-numeric # fields, with a specified one of them always coming first, then # compare the resulting tuples lexicographically. This sucks on # hexadecimal numbers but is better the rest of the time. # More functionality to add: # - count rows # - substring text search --- by default across all fields, but # changeable to be field-specific. # - "is one of" and "is not one of" relationships # - "empty or" for < and > comparisons # - dividing into sections according to a field (with a popup menu on # the table headers similar to the one on the relationship menu) # - editing and adding values (perhaps with a popup menu on the table # cells, and a + at the right edge of the headers?) # - adding per-section aggregate functions: min, max, mean, mode, # sum, count (again, on the table-header popup menu) # - relational factoring (a la DabbleDB) # - Excel import (a la Jot and Dabble) # - named views (bookmarking URLs is not enough) # - persistence of data (see how TiddlyWiki does it; maybe also Dojo # Storage) # - persistence of views (maybe just in the form of persistence of URLs) # - move all the Python code into JavaScript # - DabbleDB-style "summary" column showing all the columns not # explicitly in the view # - "leftovers" column to display mostly-null data (very similar idea) # - handling larger data sets # - for example, it would be cool to have an oerlap-like overview, # per-section or for the whole table: most common three values # for each field and their number of occurrences # - and of course you have to be a little lazier # - and maybe you could even use some materialized views to speed # OLAPpy things along a bit. # - "is more than" and "is less than" comparisons import StringIO, sys def rfc822parse(lines): "Parse a file of short RFC-822 header blocks separated by blank lines." rv = {} lastkey = None for line in lines: line = line.strip() if not line: if rv: yield rv rv = {} elif line[0] in ' \t': rv[lastkey] += ' ' + line.strip() else: lastkey, val = line.split(':', 1) rv[lastkey] = val.strip() ### HTML production, modeled after my memory of Nevow. def escape(astring): "Remove special HTML characters from astring." return (astring.replace('&', '&') .replace('<', '<') .replace('"', '"')) def as_html(something): "Render an object for inclusion in HTML, using its as_html if possible." if isinstance(something, type([])): return ''.join(map(as_html, something)) try: return something.as_html() except AttributeError: return escape(str(something)) class html_element: "A class for producing HTML elements." def __init__(self, name, attrs=None, contents=None): self.name = name self.attrs = attrs or {} self.contents = contents def as_html(self): tagstr = self.name + ''.join([' %s="%s"' % (key, as_html(val)) for key, val in self.attrs.items()]) if self.contents is None: return '<%s />' % tagstr else: return '<%s>%s</%s>' % (tagstr, self.contents, self.name) def __str__(self): return self.as_html() def content_transform(self, content): return as_html(content) def __getitem__(self, contents): if contents is None: return self.clone(contents=None) if not isinstance(contents, type(())): contents = (contents,) return self.clone(contents=''.join(map(self.content_transform, contents))) def __call__(self, **attrs): nattrs = {} for k, v in attrs.items(): if k.startswith('_'): k = k[1:] nattrs[k] = v return self.clone(attrs=nattrs) def clone(self, attrs=None, contents=None): if not attrs: attrs = {} attrs.update(self.attrs) return self.__class__(self.name, attrs, contents) class cdata_content_html_element(html_element): "A subclass for HTML elements like <script> with CDATA content model." def content_transform(self, content): return str(content) # Import a bunch of HTML into the global namespace for tag in 'table tr td th head title body p span div h1 h2 a img style'.split(): globals()[tag] = html_element(tag) script = cdata_content_html_element('script') class _html: "An easy way to get html_element instances: e.g. print html.div." def __getattr__(self, name): return html_element(name) html = _html() # I found some images I could use like this: # find ~/ -name '*.png' -size -2048c | while read fname; do echo '<img src="'"$fname"'" />'; done > images.html delete_image = "images/table-remove-column.gif" def maketable(afile, munge = lambda x: None): "Parse a file with rfc822parse and return an HTML table of its contents." records = list(rfc822parse(afile)) allkeys = {} for rec in records: munge(rec) for rec in records: for key in rec.keys(): allkeys[key] = 1 keys = allkeys.keys() # These three images come from Firefox: images = lambda colname: ( html.nobr[" ", img(src="images/table-add-row-before-active.gif", _class="sorted_up", title="sorted ascending by %s" % colname), img(src="images/table-add-row-after-active.gif", _class="sorted_down", title="sorted descending by %s" % colname), img(src=delete_image, _class="hide_column", title="hide %s" % colname)]) headers = tr[[th(title="sort by %s" % key)[key, images(key)] for key in keys]] rows = [tr[[td[rec.get(key)] for key in keys], "\n"] for rec in records] # Set cellspacing to 0 to avoid lost mouse clicks. return table(cellspacing=0, cellpadding=0)[headers, rows] css = """ * { letter-spacing: 0.08em; font-size: 96% } th, td { background: url(images/shadowTR.png); /* from Dojo */ background-repeat: no-repeat; background-position: bottom left; padding: 2px; text-align: right; } th:hover, td:hover { color: #00f } img.hide_column, img.delete_filter { padding: 2px } img.hide_column:hover, img.delete_filter:hover { background-color: #f77 } #menu div, span.pulldown { border: 1px solid #ddf; padding: 2px } span.pulldown { margin: 2px } #menu div:hover, span.pulldown:hover { background-color: #ffd } #menu { position: absolute; left: 200px; top: 200px; display: none } /* default background-color is transparent, so we use white. */ /* margin-top: -1px is to make the 1px borders not be double-width. */ #menu div { text-align: center; margin-top: -1px; background-color: white } """ allcolumns = "All columns shown." allrows = "All rows shown." javascript = """ // ======================================== basic functional stuff function forEach(list, fun) { for (var ii = 0; ii < list.length; ii++) fun(list[ii]) } // Is item an element of list? function element(item, list) { if (list.length == null) throw "attempt to find element in non-list" for (var ii = 0; ii < list.length; ii++) if (item == list[ii]) return true return false } // Return all items for which fun returns true. function filter(fun, list) { var rv = [] for (var ii = 0; ii < list.length; ii++) if (fun(list[ii])) rv.push(list[ii]) return rv } // Transform items in a list with fun. function map(fun, list) { var rv = [] for (var ii = 0; ii < list.length; ii++) rv.push(fun(list[ii])) return rv } // Copy a list-like thing so that it becomes a real list. function copylist(list) { return filter(function(){return true}, list) } // Are all the booleans in a boolean list true? function all(alist) { for (var ii = 0; ii < alist.length; ii++) if (!alist[ii]) return false return true } function not_null(value) { return value != null } // Set several attributes on an object. function set_attributes(obj, attrs) { for (var attr in attrs) obj[attr] = attrs[attr] } // Construct a closure to call a specified method. function method() { // hmm, this went from 1 line to 5 to handle arguments... var args = copylist(arguments) var name = args.splice(0, 1) return function(obj) { return obj[name].apply(obj, args) } } // ======================================== basic DOM stuff function remove_node(node) { node.parentNode.removeChild(node) } function classes(element) { return filter(function(x) { return x != ''}, element.className.split(/\s+/)) } // Not in any particularly nice order. function descendants(element) { var rv = [] // Using an explicit stack because stupid Firefox was // saying "too much recursion". This comment will be embarrassing // if the bug was in my recursive code. // function get_descendants(element, list) { // forEach(element.childNodes, function(x) { // list.push(x) // get_descendants(element, list) // }) // } var stack = [element] while (stack.length) { var elem = stack.pop() rv.push(elem) forEach(elem.childNodes, function(x) { stack.push(x) }) } return rv } function getDescendantsByClassName(elem, klass) { return filter(function(e) { return e.nodeType == e.ELEMENT_NODE && element(klass, classes(e)) }, descendants(elem)) } // find an ancestor with a specified tag. function ancestorOfType(elem, tagname) { while (elem && elem.tagName != tagname) elem = elem.parentNode return elem } function $(id) { return document.getElementById(id) } function txt(text) { return document.createTextNode(text) } function unhide(elem) {elem.style.display = ''} function hide(elem) {elem.style.display = 'none'} // ======================================== basic utility stuff function assert(condition, reason) { if (!condition) { assert_fail_reason = reason throw "assertion failure: " + reason } } // Get numeric value for sorting. // Note: empty strings are "numeric". Returns 0 for them, because NaN // seems to fuck up sorting. // Also allow leading $ and trailing ? or %%. (Note percent must be doubled // inside Python string interpolation.) function numeric_value_of(val) { var match = val.match(/^\$?(\d*(?:\.\d+)?)\??%%?$/) if (!match) return null var rv = parseFloat(match[1]) // there must be a better way to detect NaN! if ('' + rv == 'NaN') return 0 return rv } // ======================================== Column // Represents a column of the table. function Column(table_element, column_number) { assert(table_element.tagName == 'TABLE', 'no tbody?') this.table = table_element this.colnum = column_number this.title = this.cells()[0].firstChild.textContent } // All the table cell DOM objects in the column. Column.prototype.cells = function column_cells() { var rv = [] var column_num = this.colnum var self = this forEach(this.table_rows(), function(row) { var cell = self.row_cell(row) if (cell) rv.push(cell) }) return rv } // All the table cell DOM objects in the column except the header. Column.prototype.data_cells = function column_data_cells() { var rv = this.cells() rv.splice(0, 1) return rv } // All the table row DOM objects in the table. Column.prototype.table_rows = function column_table_rows() { return this.table.getElementsByTagName('tr') } // All the table row DOM objects in the table except the headers. Column.prototype.data_rows = function column_data_rows() { var rv = copylist(this.table_rows()) rv.splice(0, 1) // skip header row return rv } // Given a table row DOM object, returns the cell DOM object for this column. Column.prototype.row_cell = function column_row_cell(row) { assert(row.tagName == 'TR', "row") var ii = 0 var cell = row.firstChild while (cell != null && ii < this.colnum) { assert(cell.tagName == 'TD' || cell.tagName == 'TH', "cell") ii++ cell = cell.nextSibling } return cell } // Describe the column as a textual DOM object for when it's hidden. Column.prototype.describe_hidden = function describe_hidden() { var a = document.createElement("a") // XXX hope column name is safe for interpolation... a.href = "javascript:unhide_column('" + this.title + "')" a.appendChild(txt(this.title)) return a } // Return cell contents as strings. Column.prototype.values = function column_values() { var self = this return map(function(cell) { return cell.textContent }, this.data_cells()) } // Return true if all (data) cell contents are numeric. Column.prototype.is_numeric = function column_is_numeric() { if (this._is_numeric != null) return this._is_numeric this._is_numeric = all(map(not_null, map(numeric_value_of, this.values()))) return this._is_numeric } // Return a version of "val" suitable for comparisons, either as string or num. Column.prototype.data_value = function column_data_value(val) { if (this.is_numeric()) return numeric_value_of(val) else return val } // Return cell contents either as strings or as numbers, for comparisons. Column.prototype.data_values = function column_data_values() { var self = this return map(function(val){return self.data_value(val)}, this.values()) } Column.prototype.readable_value = function column_readable_value(value) { if (value == '') return "empty" if (this.is_numeric()) return value return "'" + value + "'" } // Sort the rows in the table. // Here's yet another JavaScript function that would be simpler with // NumPy-like array operations. Column.prototype.sort_rows = function sort_rows(reverse) { // Values is a two-column table, rather than two arrays, so // that .sort() will sort both columns. var data_values = this.data_values() var values = [] // Um, DUH. I'm already providing a comparator function. Why // don't I just FETCH THE DATA VALUE IN THE COMPARATOR FUNCTION? // XXX THIS IS STUPID. THE SCHWARTZIAN TRANSFORM IS FOR WHEN // FETCHING THE KEY IS EXPENSIVE. for (var ii = 0; ii < data_values.length; ii++) { values.push([ii, data_values[ii]]) } var up = reverse ? -1 : 1 values.sort(function(a, b) { return (a[1] < b[1]) ? -up : (a[1] > b[1]) ? up // Fall back on previous row ordering for equal keys : (a[0] < b[0]) ? -1 : (a[0] > b[0]) ? 1 : 0 }) // dump original table contents and reinsert sorted var rows = this.data_rows() assert(rows.length == values.length, "length wrong: " + rows.length + " " + values.length) var headers = this.table_rows()[0] while (headers.nextSibling) remove_node(headers.nextSibling) forEach(values, function(val) { assert(val[0] >= 0, "index too low: " + val[0]) assert(val[0] < rows.length, "index too high: " + val[0]) headers.parentNode.appendChild(rows[val[0]]) }) } // XXX neither find_column nor row_cell handles colspan or rowspan. // Given a DOM object somewhere within a column, construct a column object. function find_column(element) { var cell = ancestorOfType(element, 'TH') || ancestorOfType(element, 'TD') var row = cell.parentNode assert(row.tagName == 'TR', 'row tagname: ' + row.tagName) var colnum = 0 var seeker = row.firstChild while (seeker != cell) { assert(seeker.tagName == 'TD' || seeker.tagName == 'TH', 'cell tag') colnum++ seeker = seeker.nextSibling } return new Column(row.parentNode.parentNode, colnum) } // ======================================== common but less general DOM stuff // Append a bunch of textual DOM nodes to an element to form a textual list. // Inserts commas and " and " as appropriate. function append_comma_separated_list(elem, items) { for (var ii = 0; ii < items.length; ii++) { elem.appendChild(items[ii]) if (items.length > 2 && ii < items.length - 1) elem.appendChild(txt(", ")) else if (ii < items.length - 1) elem.appendChild(txt(" ")) if (ii == items.length - 2) elem.appendChild(txt("and ")) } } // Wrap a DOM event handler in a delaying function --- provides some // instant feedback to the user that their click registered, then runs // the "bottom-half" handler from a setTimeout of 0. There's an // optional top_half to do anything that needs to be done to the event // immediately (e.g. preventDefault, or save its currentTarget.) function delay_dom_handler(bottom_half, top_half) { return function(ev) { var original_background = ev.target.style.background ev.target.style.background = 'red' var top_half_rv if (top_half) top_half_rv = top_half(ev) var bottom_half_wrapped = function() { ev.target.style.background = original_background bottom_half(ev, top_half_rv) } setTimeout(bottom_half_wrapped, 0) } } // ======================================== hiding columns hidden_columns = [] // Update the text describing the hidden columns. function update_hidden_columns_text() { var p = $('hidden_columns') if (hidden_columns.length == 0) { p.innerHTML = "%(allcolumns)s" } else { p.innerHTML = "Showing all columns except for " append_comma_separated_list(p, map(method('describe_hidden'), hidden_columns)) p.appendChild(txt(".")) } } // Called both from javascript: urls and from code, thus the funny arg. function unhide_column(hidden_column_name) { var idx = null for (var ii = 0; ii < hidden_columns.length; ii++) { if (hidden_columns[ii].title == hidden_column_name) { idx=ii; break } } if (idx == null) return var column = hidden_columns.splice(idx, 1) update_hidden_columns_text() forEach(column[0].cells(), unhide) } function hide_column(column) { if (element(column, hidden_columns)) return hidden_columns.push(column) update_hidden_columns_text() forEach(column.cells(), hide) } // when the user clicks on the 'hide column' button function _hide_column_handler(ev) { hide_column(find_column(ev.target)) } hide_column_handler = delay_dom_handler(_hide_column_handler, method('stopPropagation')) // ======================================== sorting // Keep track of the current image indicating "sorted by" so we can hide it sorted_by_img = null function set_sorted_by_img(new_img) { if (sorted_by_img) hide(sorted_by_img) sorted_by_img = new_img unhide(sorted_by_img) } // when the user clicks on a column header, sort by that column sort_column_hdr = null sort_order = null function _sort_column(ev, th) { assert(th.tagName == 'TH', th.tagName) // I almost always want things sorted descending, so that's the default. sort_order = (sort_column_hdr == th && sort_order == 'sorted_down') ? 'sorted_up' : 'sorted_down' sort_column_hdr = th forEach(getDescendantsByClassName(th, sort_order), set_sorted_by_img) find_column(th).sort_rows(sort_order == 'sorted_down') } sort_column = delay_dom_handler(_sort_column, function(ev) { return ev.currentTarget }) // ======================================== filtering // ways to compare comparison_functions = { ' is ': function(n, a, b) { return a == b }, ' is empty or ': function(n, a, b) { return n || a == b }, ' is at least ': function(n, a, b) { return a >= b }, ' is at most ': function(n, a, b) { return a <= b }, } // Filter represents a single filtering criterion. function Filter(column, value) { this.column = column this.value = value this.relationship = ' is ' } // Construct DOM textual description of the filter. Filter.prototype.description = function filter_description() { var rv = document.createElement('span') var delete_criterion_button = document.createElement('img') set_attributes(delete_criterion_button, { src: "%(delete_image)s", title: "remove this filter", className: "delete_filter", }) delete_criterion_button.addEventListener('click', unfilter(this), false) rv.appendChild(delete_criterion_button) rv.appendChild(txt(this.column.title)) var span = document.createElement('span') set_attributes(span, { className: "pulldown", title: "change the relationship being tested", }) span.appendChild(txt(this.relationship)) span.addEventListener('mousedown', pop_up_menu(this, span), true) rv.appendChild(span) rv.appendChild(txt(this.column.readable_value(this.value))) return rv } // Change the kind of criterion --- how we compare sample value to table data Filter.prototype.change_relationship = function filter_change_relationship(r) { // If we're changing between 'is' and more complicated relationships, // we may want to hide or unhide the relevant column. var columnhiding_relationship = ' is ' var hide_unhide = ((this.relationship == columnhiding_relationship) != (r == columnhiding_relationship)) this.relationship = r if (hide_unhide) { if (r == columnhiding_relationship) hide_column(this.column) else unhide_column(this.column.title) } } // Return true if this filter would allow a row to be shown. Filter.prototype.matches = function filter_matches(row) { var cell_contents = this.column.row_cell(row).textContent var is_null = cell_contents == '' return comparison_functions[this.relationship]( is_null, this.column.data_value(cell_contents), this.column.data_value(this.value) ) } Filter.prototype.equals = function filter_equals(other) { return this.column == other.column && this.value == other.value } filters = [] // Filter list updated; update textual description and displayed rows. function update_filter_display() { // Update textual description of filter list. if (filters.length == 0) { $('row_filter').innerHTML = "%(allrows)s" } else { var p = $('row_filter') p.innerHTML = 'Showing only rows where ' var filter_descriptions = map(method('description'), filters) append_comma_separated_list(p, filter_descriptions) p.appendChild(txt(".")) } // Show all those rows that make it through the filters; hide the rest. forEach(all_data_rows, function(row) { (all(map(method('matches', row), filters)) ? unhide : hide)(row) }) } filter_using_menu = null function _menu_mouseup(ev) { $('menu').style.display = '' // default back to 'none' from the CSS document.removeEventListener('mouseup', menu_mouseup, true) if (element(ev.target, $('menu').childNodes)) { filter_using_menu.change_relationship(ev.target.textContent) update_filter_display() } filter_using_menu = null } menu_mouseup = delay_dom_handler(_menu_mouseup, function(ev) { ev.stopPropagation() ev.preventDefault() }) function pop_up_menu(filter, element) { return delay_dom_handler(function pop_up_handler(ev) { document.addEventListener('mouseup', menu_mouseup, true) filter_using_menu = filter $('menu').style.display = 'block' $('menu').style.left = element.offsetLeft + 'px' // XXX 1 is a stupid fudge factor. We subtract // element.offsetHeight to make the second menu line align. $('menu').style.top = (element.offsetTop - element.offsetHeight + 1) + 'px' // XXX why doesn't this work?! I need to go back to Remedial CSS! element.style.width = $('menu').offsetWidth + 'px !important' }, method('preventDefault')) } // Construct an event handler for a filter criterion deletion button. function unfilter(filter) { // Delete a filter criterion when the user clicks on the button to do so. return delay_dom_handler(function unfilter_handler(ev) { var filter_index for (var ii = 0; ii < filters.length; ii++) { if (filters[ii].equals(filter)) { filter_index = ii break } } var deleted_filters = filters.splice(filter_index, 1) update_filter_display() unhide_column(deleted_filters[0].column.title) }) } // what to do when the user clicks on a table cell // XXX this variable is a kludge. the hope is that it will be // initialized by the time someone calls update_filter_display. // Better to initialize it on page load. all_data_rows = null function _filter_by_example(ev) { if (ev.target.tagName == 'A' && ev.target.href) return var cell = ancestorOfType(ev.target, 'TD') var column = find_column(cell) filters.push(new Filter(column, cell.textContent)) if (!all_data_rows) all_data_rows = column.data_rows() update_filter_display() hide_column(column) } filter_by_example = delay_dom_handler(_filter_by_example) // ======================================== application setup // what to do on load function myloader(ev) { forEach(document.getElementsByTagName('TH'), function(elem) { elem.addEventListener('click', sort_column, false) forEach(getDescendantsByClassName(elem, 'sorted_up'), function(e) { e.style.display = 'none' }) forEach(getDescendantsByClassName(elem, 'sorted_down'), function(e) { e.style.display = 'none' }) forEach(getDescendantsByClassName(elem, 'hide_column'), function(e) { e.addEventListener('click', hide_column_handler, false) }) var col = find_column(elem) forEach(col.data_cells(), function(cell) { cell.addEventListener('click', filter_by_example, false) cell.title = "filter by " + col.readable_value(cell.textContent) + " in " + col.title forEach(cell.getElementsByTagName('a'), function(link) { /* don't display "filter by link in url" on link mouseover */ if (!link.href) return if (link.title) return link.title = link.href }) }) }) } window.addEventListener('load', myloader, true) """ % { 'allcolumns': allcolumns, 'allrows': allrows, 'delete_image': delete_image, } def makehtml(afile, munge=lambda x: None): "Make an HTML file from a bunch of RFC-822 headers." menu = div(id="menu")[div[" is empty or "], div[" is "], div[" is at least "], div[" is at most "]] return html.html[head[title["laptoptable table for %s" % afile], "\n", style(type="text/css")[css], "\n", script(type="text/javascript")[javascript]], "\n", body[h1[afile], "\n", menu, p[span(id="row_filter")[allrows], "\n", span(id="hidden_columns")[allcolumns]], "\n", maketable(afile, munge=munge)]] def laptop_stuff(rec): "Stuff specific to my laptop application." exchange_rage = 3.12 currency = lambda x: '$%.2f' % float(x) if rec.has_key('dollars') and not rec.has_key('pesos'): rec['pesos'] = float(rec['dollars']) * exchange_rage elif rec.has_key('pesos') and not rec.has_key('dollars'): rec['dollars'] = float(rec['pesos']) / exchange_rage if rec.has_key('dollars'): rec['pesos'] = currency(rec['pesos']) rec['dollars'] = currency(rec['dollars']) if rec.has_key('url'): rec['url'] = a(href=rec['url'])['link'] sample_laptops_file = """ url: http://articulo.mercadolibre.com.ar/MLA-26314670-ibm-thinkpad-600x-con-dvdcd-e-infrarojo-_JM ram-mb: 192 disk-gb: 12 clock-mhz: 500 battery: broken pesos: 1490 kilograms: 1.95 url: http://articulo.mercadolibre.com.ar/MLA-26161714-compaq-presario-1200-colegio-_JM pesos: 1500 ram-mb: 188 disk-gb: 6 battery: working url: http://articulo.mercadolibre.com.ar/MLA-26310670-_JM pesos: 1500 ram-mb: 192 disk-gb: 30 clock-mhz: 650 video: 1024x768 battery: working url: http://articulo.mercadolibre.com.ar/MLA-26336437-notebook-ibm-pentium-ll-64mg-ram-_JM pesos: 1500 ram-mb: 64 disk-gb: 6 clock-mhz: 400 url: http://articulo.mercadolibre.com.ar/MLA-26257874-notebook-compaq-presario-700la-_JM pesos: 1500 ram-mb: 256 disk-gb: 20 clock-mhz: 900 battery: semi-working video: 1024x768 vendor: since 2003, no points url: http://articulo.mercadolibre.com.ar/MLA-26061407-acer-travelmatte-508t-celeron-500-64mb-11gb-eze-_JM pesos: 1500 ram-mb: 64 disk-gb: 11 clock-mhz: 500 video: 800x600 battery: 1 hour url: http://articulo.mercadolibre.com.ar/MLA-25337380-toshiba-satellite-2800-s201-_JM dollars: 499 photo: none ram-mb: 128 disk-gb: 30 clock-mhz: 650 stock: out url: http://articulo.mercadolibre.com.ar/MLA-25836885-notebook-p3-700-mhz-256-ram-40gb-cddvd-envio-gratis-_JM dollars: 499.98 ram-mb: 256 disk-gb: 40 clock-mhz: 650 battery: working guarantee: 5 days video: 1024x768 url: http://articulo.mercadolibre.com.ar/MLA-25380508-ibm-thinpad-t21-3-meses-de-garanta-_JM dollars: 630 clock-mhz: 900 url: http://articulo.mercadolibre.com.ar/MLA-26297077-notebook-dell-latitude-cpxj650ctgarantia24-meses-_JM dollars: 625 clock-mhz: 600 disk-gb: 12.5 ram-mb: 128 video: 1024x768 battery: 4 hours guarantee: 24 months url: http://articulo.mercadolibre.com.ar/MLA-25662223-dell-pentium-3-1000256-20gb-impecalk-1990-_JM pesos: 1990 clock-mhz: 1000 ram-mb: 256 disk-gb: 20 battery: 2 hours url: http://articulo.mercadolibre.com.ar/MLA-26229000-_JM pesos: 1500 clock-mhz: 33? ram-mb: 12 video: 800x600 url: http://articulo.mercadolibre.com.ar/MLA-26320681-notebook-toshiba-satellite-4600-pro-liquido-ya-_JM pesos: 1450 clock-mhz: 800 ram-mb: 128 disk-gb: 18.6 video: 1024x768 battery: 2 hours url: http://articulo.mercadolibre.com.ar/MLA-25950089-_JM pesos: 1350 clock-mhz: 700 ram-mb: 128 disk-gb: 12 battery: dead url: http://articulo.mercadolibre.com.ar/MLA-26202298-laptop-compaq-presario-700-1300-_JM pesos: 1150 clock-mhz: 500 ram-mb: 256 disk-gb: 20 battery: 0.25 hours vendor: no points url: http://articulo.mercadolibre.com.ar/MLA-26083337-notebook-toshiba-tecra-8000-pentium-ii-450-mhs-o-permuto-_JM pesos: 1290 clock-mhz: 450 ram-mb: 64 disk-gb: 3.8 battery: baja model: Tecra 8000 url: http://articulo.mercadolibre.com.ar/MLA-26366998-compaq-armada-e500-en-muy-buen-estado-_JM pesos: 1250 clock-mhz: 700 ram-mb: 256 disk-gb: 12 battery: broken url: http://articulo.mercadolibre.com.ar/MLA-26228657-ibm-thinkpad-i-series-1200-en-caja-batera-nueva-_JM dollars: 389.99 clock-mhz: 500 ram-mb: 160 disk-gb: 6 battery: 2 hours guarantee: 3 meses url: http://articulo.mercadolibre.com.ar/MLA-26301698-compaq-presario-1200-_JM pesos: 1200 ram-mb: 60 disk-gb: 6.1 url: http://articulo.mercadolibre.com.ar/MLA-26328755-_JM pesos: 1200 clock-mhz: 500 ram-mb: 256 disk-gb: 10 battery: broken url: http://articulo.mercadolibre.com.ar/MLA-26342257-_JM pesos: 1880 clock-mhz: 900 ram-mb: 128 disk-gb: 20 battery: broken guarantee: 3 months vendor: has 5% negatives on 312 points url: http://articulo.mercadolibre.com.ar/MLA-25931352-notebook-amd-duron-950mhz-256ram-20gb-dvdcd-envio-gratis-_JM dollars: 564.98 clock-mhz: 950 ram-mb: 256 disk-gb: 20 battery: 2 hours url: http://articulo.mercadolibre.com.ar/MLA-25819694-notebook-p3-1ghz-256ram-20gb-cd-rw-envio-gratis-_JM dollars: 549.98 clock-mhz: 1000 ram-mb: 256 disk-gb: 20 battery: 2 hours url: http://articulo.mercadolibre.com.ar/MLA-25819756-notebook-p3-700mhz-256-ram-30gb-dvd-envio-gratis-_JM dollars: 539.98 clock-mhz: 700 ram-mb: 256 disk-gb: 30 battery: 2 hours url: http://articulo.mercadolibre.com.ar/MLA-26051957-_JM dollars: 530 clock-mhz: 800 ram-mb: 128 disk-gb: 20 battery: 2 hours video: 1024x768? model: T21 url: http://articulo.mercadolibre.com.ar/MLA-26328242-_JM pesos: 1600 clock-mhz: 650 ram-mb: 512 url: http://articulo.mercadolibre.com.ar/MLA-26387632-notebook-pentium-iii-nec-400-mhz-wireless-super-delgada-_JM pesos: 1550 clock-mhz: 400 ram-mb: 192 disk-gb: 20 battery: 4 hours video: 1024x768 url: http://articulo.mercadolibre.com.ar/MLA-26227760-notebook-compaq-armada-e500-iii900-256-ram-hd-20-g-143-_JM dollars: 499.99 clock-mhz: 900 ram-mb: 256 disk-gb: 20 video: 1280x1024 """ if __name__ == "__main__": if len(sys.argv) == 1: infile = StringIO.StringIO(sample_laptops_file) else: infile = file(sys.argv[1]) print makehtml(infile, munge=laptop_stuff)