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('&', '&amp;')
                   .replace('<', '&lt;')
                   .replace('"', '&quot;'))

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)

Reply via email to