Tight TV Grid

By Chris Noé Last update Sep 19, 2008 — Installed 2,310 times.

There are 3 previous versions of this script.

Add Syntax Highlighting (this will take a few seconds, probably freezing your browser while it works)

// $Id: tighttvgrid.user.js 354 2008-09-12 22:37:09Z Chris $
// -----------------------------------------------------------------------------
// This is a Greasemonkey user script.
// To use it, first install Greasemonkey: http://www.greasespot.net/
// Then restart Firefox and revisit this script
// From the Firefox menu select: Tools -> Install User Script
// Accept the default configuration and install
// Now when you visit any of the supported sites you will see extra funcionality
// Documentaion here: http://refactoror.net/greasemonkey/TightTVGrid/doc.html
// -----------------------------------------------------------------------------
// ==UserScript==
// @name         Tight TV Grid
// @namespace    http://refactoror.net/
// @description  Operates on multiple TV listings services. Removes content surrounding the listing grid and adds an IMDb link in front of each program title.
// @version      3.0
// @author       Chris Noe
// @include      http://www.excite.com/tv/*
// ==/UserScript==

var VERSION = "3.0";
var ident = "ttgver=" + VERSION;


// --------------- Initialization ---------------

extendJavascriptObjects();

// The values listed here are the first-time-use defaults
// They have no effect once they are stored as mozilla preferences.
var prefs = new Preferences({
    "controlBar-isExpanded":   false,
    "favoredTitles":           "",
    "favoredTitlesColor":      "#FF9900",
    "favoriteTitles":          "",
    "favoriteTitlesColor":     "#FFFF00",
    "fixedHeader":             true,
    "highlightFirstRun":       true,
    "ignoreTitles":            "",
    "ignoreTitlesColor":       "#996666",
    "insertImdbLinks":         true,
    "insertTvcomLinks":        true,
    "linksAlwaysOpenInNewTab": true,
    "loggerLevel":             "WARN",
    "omitChannels":            "",
    "prefsMenuAccessKey":      "P",
    "prefsMenuPosition":       "BR",
    "prefsMenuVisible":        true,
    "refreshMinute":           3,
    "removeGridAds":           true,
    "removeNonGridElements":   true,
    "searchTitleActor-isExpanded": false,
    "sendAnonymousStatistics": true,
    "showTimeMarker":          true,
    "tweakLayout":             true
});

var log = new Logger();

// --------------- Page handlers ---------------

tryCatch("ttg", enhanceExciteListingPage);


function enhanceExciteListingPage()
{
    if (document.location.href.match("grid.jsp") == null) {
        log.info("This is not the grid page, no processing...");
        return;
    }

    var exciteDoc = extendListingDocument(document);

    if (exciteDoc == null)
        return null;

    if (exciteDoc.isEmpty()) {
        // failed page load re-try
        exciteDoc.schedulePageRefresh(1);
        return null;
    }
    else {
        // refresh on the upcoming hour
        exciteDoc.scheduleUpcomingHourRefresh(prefs.get("refreshMinute"), function() {
            window.location.reload();
        });
    }

    var programTypeMap = new Array();
    exciteDoc.foreachNode(
        "//*[text()='Color Key']/following::table[1]//tr[position()!=1]/td",
        function(td) {
            programTypeMap[td.bgColor] = td.textContent;
        }
    );

    dispatchFeature("removeNonGridElements", function() {
        // -- top of page
        // search bar
        exciteDoc.hideNodes("//select[@name='featuredguides']/ancestor::table[4]");
        // nav bar
        exciteDoc.hideNodes("//a[contains(@href, 'entertainment.excite.com')]/ancestor::table[2]");
        // title row
        exciteDoc.hideNodes("//a[contains(@href, 'tv/data.jsp')]/ancestor::table[1]");

        // -- bottom of page
        // search title/actor
        exciteDoc.selectNode("//*[text()='Search by Title or Actor']/ancestor::p[1]")
            .makeCollapsible("searchTitleActor-isExpanded", true);
        // color key
        exciteDoc.hideNodes("//*[text()='Color Key']/ancestor::p[1]");
        // ad
        exciteDoc.hideNodes("//div[@id='adFooter']/ancestor::table[1]");
        // web search bar
        exciteDoc.hideNodes("//form[@name='footerSearch']/ancestor::table[3]");
        // sitemap
        exciteDoc.hideNodes("//a[contains(@href, 'site_map/index.html')]/ancestor::table[1]");
        // extra spacing
        exciteDoc.hideNodes("//br");
    });

    exciteDoc.selectNode("//form[@name='gridform']/ancestor::table[1]")
        .makeCollapsible("controlBar-isExpanded", true);

    dispatchFeature("fixedHeader", function()
    {
        // experimental
        exciteDoc.hideNodes("//*[@bgcolor='white']/ancestor::tr[1]");
        exciteDoc.hideNodes("//*[text()='Basic Channels']/ancestor::tr[1]");
        var header = exciteDoc.selectNode(
            "//a[starts-with(@href, 'http://www.excite.com/tv/grid.jsp')][1]/ancestor::tr[1]");
        exciteDoc.hideNodes(
            "//a[starts-with(@href, 'http://www.excite.com/tv/grid.jsp')]/ancestor::tr[1]");
        header.show();
    });

    var channelMatchers = prefs.getAsList("omitChannels", ";", ChannelMatcher);
    if (channelMatchers != null && channelMatchers != "")
    {
        // remove specified channels
        exciteDoc.foreachNode
        (
            "//a[starts-with(@href, 'http://my.excite.com/tv/chan.jsp')]/text()",
            function(channelName_text)
            {
                var chanParts = channelName_text.textContent.split(" ");
                var isOmit = false;
                for (var c in channelMatchers)
                {
                    for (var p in chanParts) {
                        if (channelMatchers[c].match(chanParts[p])) {
                            try {
                                var chan_tr = channelName_text.selectNode("ancestor::tr[1]");
                                chan_tr.remove();
                            }
                            catch(err) {
                                log.info("Trying to delete '" + channelName_text.textContent
                                + "', row previously deleted");
                            }
                            break;
                        }
                    }
                }
                if (isOmit == true) {
                }
            }
        );
    }

    // encapsulates a channel spec value (name/number/range) and requisite match() method
    function ChannelMatcher(chanSpec)
    {
        var i = chanSpec.toString().indexOf("-");
        if (i > 0) {
            // range matcher
            this.lo = parseInt(chanSpec.substring(0, i));
            this.hi = parseInt(chanSpec.substring(i + 1));
            this.match = function(chan) {
                var f = (chan >= this.lo && chan <= this.hi);
                return f;
            }
        }
        else {
            // simple equality matcher
            this.chanSpec = chanSpec;
            this.match = function(chan) {
                var f = (chan == this.chanSpec);
                return f;
            }
        }
    }

    var favoriteTitles = prefs.getAsList("favoriteTitles", ";");
    var favoredTitles = prefs.getAsList("favoredTitles", ";");
    var ignoreTitles = prefs.getAsList("ignoreTitles", ";");

    // process each title link in the grid
    var first_td = exciteDoc.selectNode("//a[starts-with(@href, 'http://www.excite.com/tv/grid.jsp')][1]/ancestor::td[1]");
    var last_td;
    exciteDoc.foreachNode
    (
        "//a[starts-with(@href, 'http://www.excite.com/tv/prog.jsp')]",
        function(programTitle_a)
        {
            var programAttrs = new Object();
            programAttrs.title = unescape(programTitle_a.textContent.normalizeWhitespace());

            var td = programTitle_a.selectNode("ancestor::td[1]");
            programAttrs.programType = programTypeMap[td.bgColor];

            var subTitle_i = programTitle_a.selectNodeNullable("following-sibling::i[1]");
            if (subTitle_i != null) {
                programAttrs.subTitle = subTitle_i.textContent;
            }

            var attributes = programTitle_a.selectNodeNullable("following-sibling::text()");
            if (attributes != null)
            {
                attributes = attributes.textContent.normalizeWhitespace();

                // get New/Repeat indicator, if present
                if (attributes.substring(0, 1) == "(") {
                    var i = attributes.indexOf(")");
                    if (i != -1) {
                        programAttrs.new_repeat = attributes.substring(1, i);
                        attributes = attributes.substring(i+1).trimWhitespace();
                    }
                }

                // get remaining attributes
                tokens = attributes.split(",");
                var t = 0;
                if (programAttrs.programType == "Movies") {
                    programAttrs.isMovie = true;
                    programAttrs.year = attributes.match(/\d\d\d\d/);
                    programAttrs.runtime = attributes.match(/\d\d:\d\d/);
                }
                else {
                    if (tokens[t])
                        programAttrs.subcat = tokens[t].trimWhitespace();
                    if (tokens[++t])
                        programAttrs.subsubcat = tokens[t].trimWhitespace();
                }
            }

            exciteDoc.insertLinks(programTitle_a, programAttrs);

            if (favoriteTitles != null && favoriteTitles.contains(programAttrs.title)) {
                programTitle_a.style.backgroundColor = prefs.get("favoriteTitlesColor");
            }
            if (favoredTitles != null && favoredTitles.contains(programAttrs.title)) {
                programTitle_a.style.backgroundColor = prefs.get("favoredTitlesColor");
            }
            if (ignoreTitles != null && ignoreTitles.contains(programAttrs.title)) {
                programTitle_a.style.color = prefs.get("ignoreTitlesColor");
            }
            dispatchFeature("highlightFirstRun", function() {
                if (programAttrs.new_repeat == "New") {
                    programTitle_a.style.fontWeight = "bold";
                    programTitle_a.style.fontSize = "120%";
                }
            });
            last_td = programTitle_a.selectNode("ancestor::td[1]");
        }
    );

    // (this must be the last modification to the grid structure)
    dispatchFeature("showTimeMarker", function()
    {
        var gridLeftNav_a = exciteDoc.selectNode(
            "//a[starts-with(@href, 'http://www.excite.com/tv/grid.jsp')][1]");
        var hour1_00_text = gridLeftNav_a.selectNode("following::text()[1]");
        var gridStartDate = parseGridTime(hour1_00_text.textContent);

        var hour1_00_td = gridLeftNav_a.selectNode("ancestor::td[1]");
        var hour1_30_td = hour1_00_td.selectNode("following::td[1]");
        var tm;
        if ( (new Date()).getMinutes() < 30 ) {
            tm = new TimeMarker(
                hour1_00_td, hour1_00_td, hour1_00_td, last_td,
                gridStartDate, 0.5 * HOUR
            );
        }
        else {
            var relDate = gridStartDate;
            relDate.setMinutes(30);
            tm = new TimeMarker(
                hour1_30_td, hour1_30_td, hour1_30_td, last_td,
                relDate, 0.5 * HOUR
            );
        }
        exciteDoc.selectNode("//body").appendSibling(tm);
    });

    function parseGridTime(str)
    {
        var tim = str.trimWhitespace().split(" ");
        var hr_min = tim[0].split(":");
        var meridiem = 0;
        if (tim[1] == "PM" && Number(hr_min[0]) < 12)
            meridiem = 12;
        var gridDate = new Date();
        gridDate.setHours( (Number(hr_min[0]) + meridiem), hr_min[1], 00, 000);

        return gridDate;
    }
}


function extendListingDocument(doc)
{
    extendDocument(doc);

    if (doc == null)
        return null;

    // Refresh this page on the upcoming hour, plus the specified number of minutes.
    // (Disabled if negative)
    doc.scheduleUpcomingHourRefresh = function(refreshMinute, func)
    {
        if (refreshMinute < 0) {
            log.info("Not configured for auto-refresh");
            return;
        }
        var now = new Date();
        var refreshTime = now.floor(HOUR).add(HOUR).add(refreshMinute * MINUTE);

        window.setTimeout(func, (refreshTime.getTime() - now.getTime()) );
        log.info("Scheduled page refresh: " + refreshTime);
    }

    // Refresh this page in the the specified number of minutes.
    doc.schedulePageRefresh = function(refreshMinute, func)
    {
        var now = new Date();
        var refreshTime = now.add(refreshMinute * MINUTE);

        window.setTimeout(
            function() { window.location.reload(); },
            (refreshTime.getTime() - now.getTime())
        );
        log.info("Scheduled page refresh: " + refreshTime);
    }

    // insert external search link(s) in front of the specified node
    doc.insertLinks = function(base_node, programAttrs)
    {
        theDoc = this;
        dispatchFeature("insertImdbLinks", function()
        {
        	var imdbLink = createExternalLink("http://www.imdb.com/favicon.ico");
            var IMDB_SEARCH_TT = "http://imdb.com/find?s=tt&q=";
            var IMDB_SEARCH_EP = "http://imdb.com/find?s=ep&q=";

            var year = "";
            if (programAttrs.year != null) {
                year = " (" + programAttrs.year + ")";
            }

            if (programAttrs.subTitle != null) {
                // episode search
                imdbSearchTerm = '"' + programAttrs.subTitle + '"' + year;
            	imdbLink.title = "Search for episode " + imdbSearchTerm + " on imdb.com";
                base_node.prependSibling(theDoc.createLink(
                    imdbLink, IMDB_SEARCH_EP + escape(imdbSearchTerm) ));
            }
            else {
                // title search
                imdbSearchTerm = '"' + programAttrs.title + '"' + year;
            	imdbLink.title = "Search for " + imdbSearchTerm + " on imdb.com";
                base_node.prependSibling(theDoc.createLink(
                    imdbLink, IMDB_SEARCH_TT + escape(imdbSearchTerm) ));
            }
        });

        dispatchFeature("insertTvcomLinks", function()
        {
        	var tvcomLink = createExternalLink("http://www.tv.com/favicon.ico");
            var TVCOM_SEARCH = "http://www.tv.com/search.php?type=11&stype=all&tag=search;button&qs=";

            tvcomTerm = '"' + programAttrs.title + '"';
        	tvcomLink.title = "Search for " + tvcomTerm + " on tv.com";
            if (programAttrs.isMovie != true) {
                base_node.prependSibling(
                    theDoc.createLink(tvcomLink, TVCOM_SEARCH + tvcomTerm));
            }
        });

        function createExternalLink(url)
        {
        	var img = document.createXElement('img', { src: url });
        	with (img.style) {
            	border = 0;
            	width = "16px";
            	height = "14px";
            	verticalAlign = "-25%";
            }
            return img;
        }
    }

    doc.createLink = function(symbol, url)
    {
        var lookup_a = this.createXElement("a");
        lookup_a.className = "lookup_a";
        lookup_a.href = url;
        lookup_a.appendChild(symbol);

        if (prefs.get("linksAlwaysOpenInNewTab") == true) {
            lookup_a.target = "_blank";
        }

        return lookup_a;
    }

    // Prefs menu button
    if (prefs.get("prefsMenuVisible") == true) {
        menuButton = doc.createXElement("button", { textContent: "Prefs" });
        setScreenPosition(menuButton, prefs.get("prefsMenuPosition"));
    }
    else {
        menuButton = doc.createXElement("a");
        menuButton.hide();
    }
    with (menuButton) {
        id = "ttg_prefs_menu_button";
        title = "Tight TV Grid Preferences";
        style.fontSize = "9pt";
        addEventListener('click', openPrefsDialog, false);
        accessKey = prefs.get("prefsMenuAccessKey");
    }
    doc.selectNode("//body").appendSibling(menuButton);

    // Prefs dialog
    function openPrefsDialog(event)
    {
        var prefsMgr = new PreferencesManager(
            event.target.ownerDocument,
            "ttg_prefs",
            "Tight TV Grid Preferences",
            { OK: function okPrefs(doc) { prefsMgr.storePrefs(); },
              Cancel: noop
            }
        );
        var prefsDialog_div = prefsMgr.open();
        if (prefsDialog_div == null)
            return;  // the dialog is already open

        with (prefsDialog_div)
        {
            var mainTabset = new TabSet(doc, "iwvr_mainTabset", ["General", "Highlighting"]);
            appendChild(mainTabset.container_div);

            with (mainTabset.getTabContent_div("General"))
            {
                var table = doc.createXElement("table");
                appendChild(table);

                var tr = doc.createXElement("tr");
                table.appendChild(tr);

                var td = doc.createXElement("td");
                td.style.verticalAlign = "top";
                tr.appendChild(td);
                with (td)
                {
                    style.verticalAlign = "top";
                    var gridFeatures_div = doc.createTopicDiv("Grid Layout", td);
                    appendChild(gridFeatures_div);
                    with (gridFeatures_div.contentElement)
                    {
                        appendChild(prefsMgr.createPreferenceInput(
                            "removeNonGridElements",
                            "Isolate listing grid",
                            "Remove content surrounding the listing grid"
                        ));
                        appendChildElement("br");
                        appendChild(prefsMgr.createPreferenceInput(
                            "tweakLayout",
                            "Tweak layout",
                            "Adjust font styles, etc"
                        ));
                        appendChildElement("br");
                        appendChild(prefsMgr.createPreferenceInput(
                            "showTimeMarker",
                            "Show current time",
                            "Indicate the current time as a vertical dashed line over the grid"
                        ));
                        var div = appendChildText("Remove Channels:", ["div"]);
                        div.style.marginTop = "5px";
                        appendChild(prefsMgr.createPreferenceInput(
                            "omitChannels", null,
                            "Remove these channels from the grid",
                            { size: 24 }
                        ));
                    }

                    var linkFeatures_div = doc.createTopicDiv("External Links", td);
                    appendChild(linkFeatures_div);
                    with (linkFeatures_div.contentElement)
                    {
                        appendChild(prefsMgr.createPreferenceInput(
                            "insertImdbLinks",
                            "Add imdb.com links",
                            "Add an imdb.com search link in front of each program title"
                        ));
                        appendChildElement("br");
                        appendChild(prefsMgr.createPreferenceInput(
                            "insertTvcomLinks",
                            "Add tv.com links",
                            "Add a tv.com search link in front of each program title"
                        ));
                        appendChildElement("br");
                        appendChild(prefsMgr.createPreferenceInput(
                            "linksAlwaysOpenInNewTab",
                            "Links open in a new tab",
                            ""
                        )).style.marginLeft = "16px";
                    }
                }

                var td = doc.createXElement("td");
                td.style.verticalAlign = "top";
                tr.appendChild(td);
                with (td)
                {
                    style.verticalAlign = "top";
                    var prefsDock_div = doc.createTopicDiv("Dock Prefs Menu", td);
                    appendChild(prefsDock_div);
                    with (prefsDock_div.contentElement)
                    {
                        appendChild(prefsMgr.createPreferenceInput(
                            "prefsMenuVisible",
                            "Visible",
                            "Prefs menu button visible on the screen"
                        ));
                        with (appendChild(prefsMgr.createScreenCornerPreference("prefsMenuPosition"))) {
                            title = "Screen corner for [Prefs] menu button";
                            style.margin = "1px 0px 3px 20px";
                        }
                        appendChild(prefsMgr.createPreferenceInput(
                            "prefsMenuAccessKey",
                            "Access Key",
                            "Alt-Shift keyboard shortcut",
                            { size:1, maxLength: 1 }
                        ));
                    }

                    var miscFeatures_div = doc.createTopicDiv("Miscellaneous", td);
                    appendChild(miscFeatures_div);
                    with (miscFeatures_div.contentElement)
                    {
                        appendChild(prefsMgr.createPreferenceInput(
                            "removeGridAds",
                            "Remove advertising",
                            "Remove advertising"
                        ));
                        with (appendChildElement("div")) {
                            style.margin = "2px";
                        }
                        appendChild(prefsMgr.createPreferenceInput(
                            "refreshMinute",
                            "Auto-refresh minute",
                            "Refresh the listing these many minutes after each hour",
                            { size:1, maxLength: 2 }
                        ));
                    }

                    var controls_div = doc.createTopicDiv("Advanced Controls", td);
                    appendChild(controls_div);
                    with (controls_div.contentElement)
                    {
                        appendChild(prefsMgr.createPreferenceInput(
                            "loggerLevel",
                            "Logging Level",
                            "Control level of information that appears in the Error Console",
                            null,
                            log.getLogLevelMap()
                        ));
                    }
                }
            }

            with (mainTabset.getTabContent_div("Highlighting"))
            {
                appendChild(prefsMgr.createPreferenceInput(
                    "highlightFirstRun",
                    "Emphasize first run programs",
                    "Emphasize first run program titles (bold)"
                ));

                var highFeatures_div = doc.createTopicDiv("Customize Program Titles", prefsDialog_div);
                appendChild(highFeatures_div);
                with (highFeatures_div.contentElement)
                {
                    var tabset = new TabSet(doc, "iwvr_highlightingTabset",
                            ["Favorites", "Favored", "Ignored"]);
                    appendChild(tabset.container_div);

                    with (tabset.getTabContent_div("Favorites"))
                    {
                        appendChild(prefsMgr.createPreferenceInput(
                            "favoriteTitles", null,
                            "Highlight these titles",
                            { rows: 6, cols: 25 }
                        ));
                        appendChild(prefsMgr.createPreferenceInput(
                            "favoriteTitlesColor",
                            "Color", "",
                            { size:7 }
                        ));
                    }
                    with (tabset.getTabContent_div("Favored"))
                    {
                        appendChild(prefsMgr.createPreferenceInput(
                            "favoredTitles", null,
                            "Highlight these titles, more subtly",
                            { rows: 6, cols: 25 }
                        ));
                        appendChild(prefsMgr.createPreferenceInput(
                            "favoredTitlesColor",
                            "Color", "",
                            { size:7 }
                        ));
                    }
                    with (tabset.getTabContent_div("Ignored"))
                    {
                        appendChild(prefsMgr.createPreferenceInput(
                            "ignoreTitles", null,
                            "De-emphasize these titles",
                            { rows: 6, cols: 25 }
                        ));
                        appendChild(prefsMgr.createPreferenceInput(
                            "ignoreTitlesColor",
                            "Color", "",
                            { size:7 }
                        ));
                    }
                    tabset.initialize();
                }
            }
            mainTabset.initialize();

            // Help link
            var docs_div = doc.createXElement("div");
            appendChild(docs_div);
            with (docs_div) {
                appendChild(doc.createHtmlLink(
                    "http://refactoror.com/greasemonkey/TightTVGrid/doc.html#prefs",
                    "Help"
                ));
                align = "center";
                style.padding = "3px";
            }
        }
    }

    return doc;
}


// ==================== TimeMarker object ====================

// Install a real-time time indicator over the grid
function TimeMarker(topRefNode, leftRefNode, rightRefNode, bottomRefNode, startTime, duration, offsetX)
{
    var topY = topRefNode.findPosY();
    var bottomY = bottomRefNode.findPosY() + bottomRefNode.clientHeight;
    var heightY = bottomY - topY;

    var leftX = leftRefNode.findPosX();
    var rightX = rightRefNode.findPosX() + rightRefNode.clientWidth;
    var widthX = rightX - leftX;

    // adjustment for irregular grids
    if (offsetX == null)
        offsetX = 0;

    var timemarker_div = document.createElement("div");
    timemarker_div.id = "ttg_timemarker";
    with (timemarker_div.style) {
        border = ".75px dashed red";
        position = "absolute";
        top = topY;
        height = heightY;
        zIndex = 99;
    }

    this.refresh = function()
    {
        var xleft  = leftRefNode.findPosX() + offsetX;
        var xright = rightRefNode.findPosX() + rightRefNode.clientWidth;

        var now = new Date();
        var hourFrac = (now.getTime() - startTime.getTime()) / duration;
        var x = xleft + Math.floor(hourFrac * (xright - xleft));

        timemarker_div.style.left = x;
        timemarker_div.title = formatGridTime(now);
    }
    this.refresh();

    window.setInterval(this.refresh, 5000);
    window.addEventListener("resize", this.refresh, false);

    return timemarker_div;
}

function formatGridDate(gridtime)
{
    var s = gridtime.toDateString().split(" ");
    return s[0] + ", " + s[1] + " " + s[2];
}

function formatGridTime(gridtime)
{
    var d = new Date(gridtime);
    var h = d.getHours();
    var xm = "am";
    if (h > 12) {
        h -= 12;
        xm = "pm";
    }
    var m = "0" + d.getMinutes();
    return h + ":" + m.substring(m.length - 2) + xm;
}


// =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
// =-=-=-=-=-=-=-=-=-=-=-= DOM Monkey -=-=-=-=-=-=-=-=-=-=-=-=
// =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=

// The following code assumes the following objects have been created, (in this order):
// var prefs = new Preferences(...);
// var log = new Logger();


// ==================== DOM object extensions ====================

/** Extend the given document with methods
* for querying and modifying the document object.
*/
function extendDocument(doc)
{
    if (doc == null)
        return null;

    /** Determine if the current document is empty.
    */
    doc.isEmpty = function() {
        return (this.body == null || this.body.childNodes.length == 0);
    };

    /** Report number of nodes that matach the given xpath expression.
    */
    doc.countNodes = function(xpath) {
        var n = 0;
        this.foreachNode(xpath, function(node) {
            n++;
        });
        return n;
    };

    /** Remove nodes that match the given xpath expression.
    */
    doc.removeNodes = function(xpath) {
        this.foreachNode(xpath, function(node) {
            node.remove();
        });
    };

    /** Hide nodes that match the given xpath expression.
    */
    doc.hideNodes = function(xpath)
    {
        if (xpath instanceof Array) {
            for (xp in xpath) {
                this.foreachNode(xp, function(node) {
                    node.hide();
                });
            }
        }
        else {
            this.foreachNode(xpath, function(node) {
                node.hide();
            });
        }
    };

    /** Make visible the nodes that match the given xpath expression.
    */
    doc.showNodes = function(xpath) {
        this.foreachNode(xpath, function(node) {
            node.show();
        });
    };

    /** Retrieve the value of the node that matches the given xpath expression.
    */
    doc.selectValue = function(xpath, contextNode)
    {
        if (contextNode == null)
            contextNode = this;

        var result = this.evaluate(xpath, contextNode, null, XPathResult.ANY_TYPE, null);
        var resultVal;
        switch (result.resultType) {
            case result.STRING_TYPE:  resultVal = result.stringValue;  break;
            case result.NUMBER_TYPE:  resultVal = result.numberValue;  break;
            case result.BOOLEAN_TYPE: resultVal = result.booleanValue; break;
            default:
                log.error("Unhandled value type: " + result.resultType);
        }
        return resultVal;
    }

    /** Select the first node that matches the given xpath expression.
    * If none found, log warning and return null.
    */
    doc.selectNode = function(xpath, contextNode)
    {
        var node = this.selectNodeNullable(xpath, contextNode);
        if (node == null) {
            // is it possible that the structure of this web page has changed?
            log.warn("XPath returned no elements: " + xpath
                + "\n" + genStackTrace(arguments.callee)
            );
        }
        return node;
    }

    /** Select the first node that matches the given xpath expression.
    * If none found, return null.
    */
    doc.selectNodeNullable = function(xpath, contextNode)
    {
        if (contextNode == null)
            contextNode = this;

        var resultNode = this.evaluate(
            xpath, contextNode, null,
            XPathResult.FIRST_ORDERED_NODE_TYPE, null);

        return extendNode(resultNode.singleNodeValue);
    }

    /** Select all nodes that match the given xpath expression.
    * If none found, return null.
    */
    doc.selectNodeSet = function(xpath, contextNode)
    {
        if (contextNode == null)
            contextNode = this;

        var nodeSet = this.evaluate(
            xpath, contextNode, null,
            XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);

        return nodeSet;
    }

    /** Iteratively execute the given func for each node that matches the given xpath expression.
    */
    doc.foreachNode = function(xpath, func, contextNode)
    {
        if (contextNode == null)
            contextNode = this;

        // if array of xpath strings, call recursively
        if (xpath instanceof Array) {
            for (i in xpath)
                this.foreachNode(xpath[i], func, contextNode);
            return;
        }

        var nodeSet = contextNode.selectNodeSet(xpath, contextNode);

        var i = 0;
        var n = nodeSet.snapshotItem(i);
        while (n != null) {
            var result = func(extendNode(n));
            if (result == false) {
                // dispatching func can abort the loop by returning false
                return;
            }
            n = nodeSet.snapshotItem(++i);
        }
    }

    /** Sleep until the given xpath expression returns non-empty content,
    * then dispatch to the given function.
    */
    doc.onAppears = function(xpath, interval, dispatchFunc)
    {
        var camponTimer = null;

        function campon()
        {
            if (this.selectNodeNullable(xpath) == null) {
                return;
            }
            window.clearInterval(camponTimer);
            dispatchFunc();
        }
        camponTimer = window.setInterval(campon, interval);
    }

    doc.onAppearsStable = function(xpath, interval, dispatchFunc)
    {
        var thedoc = this;
        var camponTimer = null;

        function campon()
        {
            if (thedoc.selectNodeNullable(xpath) == null) {
                log.debug("still waiting for initial content to load...");
                return;
            }
            window.clearInterval(camponTimer);
            log.info("intial content has loaded: " + xpath);
            thedoc.onStable(xpath, interval, dispatchFunc);
        }
        log.info("waiting for initial content to load...");
        camponTimer = window.setInterval(campon, interval);
    }

    // sleep until content return by xpath stops changing size
    doc.onStable = function(xpath, interval, dispatchFunc)
    {
        var thedoc = this;
        var camponTimer = null;
        var prevLen = thedoc.selectNodeSet(xpath).snapshotLength;

        function campon()
        {
            var curLen = thedoc.selectNodeSet(xpath).snapshotLength;
            if (curLen != prevLen) {
                log.debug("(" + curLen + ") still waiting for content to stablize...");
                prevLen = curLen;
                return;
            }
            window.clearInterval(camponTimer);
            log.info("(" + curLen + ") content has stablized: " + xpath);
            dispatchFunc();
        }
        log.debug("(" + prevLen + ") waiting for content to stablize...");
        camponTimer = window.setInterval(campon, interval);
    }

    /** Retrieve the text content of the node that matches the given xpath expression.
    */
    doc.selectTextContent = function(xpath) {
        var node = this.selectNodeNullable(xpath, this);
        if (node == null)
            return null;
        return node.textContent.normalizeWhitespace();
    };

    /** Retrieve the text content of the node that matches the given xpath expression,
    * and apply the given regular expression to it, returning the portion that matches.
    */
    doc.selectMatchTextContent = function(xpath, regex) {
        var text = this.selectTextContent(xpath);
        if (text == null)
            return null;
        return text.match(regex);
    };

    /** Replace contents of contextNode (default: body), with specified node.
    * (The specified node is removed, then re-added to emptied contextNode.)
    * The specified node is expected to be a descendent of the context node.
    * Otherwise the result is probably an error.
    * DOC-DEFAULT
    */
    doc.isolateNode = function(xpath, contextNode)
    {
        if (contextNode == null)
            contextNode = this.body;

        extendNode(contextNode);

        var subjectNode = this.selectNode(xpath);
        if (subjectNode == null || subjectNode.parentNode == null)
            return;

        // gut the parent node (leave script elements alone)
        contextNode.foreachNode("child::*", function(node) {
            if (node.tagName != "SCRIPT" && node.tagName != "NOSCRIPT") {
                node.remove();
            }
        });

        // re-add the subject node
        var replacement_div = this.createElement("div");
        replacement_div.id = "isolateNode:" + xpath;
        replacement_div.appendChild(subjectNode);

        contextNode.appendChild(replacement_div);
    };

    /** Add a <script> reference to this document.
    * DOC-CENTRIC
    */
    doc.addScriptReference = function(url)
    {
        var script = this.createElement("script");
        script.src = url;
        this.selectNode("//head").appendChild(script);

        return script;
    }

    /** Add a CSS style definition to this document.
    * DOC-CENTRIC
    */
    doc.addStyle = function(cssBody, id)
    {
        var style = this.createXElement("style");
        style.innerHTML = cssBody;
        this.selectNode("//head").appendChild(style);

        return style;
    }

    /** Create an "extended" HTML element of the specified type,
    * with the given attributes applied to it.
    * The returned object is extended by extendNode().
    * DOC-NONSPECIFIC
    */
    doc.createXElement = function(tagName, attrMap)
    {
        var node = extendNode(this.createElement(tagName));
        node.applyAttributes(attrMap);
        return node;
    }

    /** Create
    */
    doc.createHtmlLink = function(url, text, attrMap)
    {
        var a = this.createXElement("a");
        a.href = url;
        if (text == null) {
            text = url;
        }
        a.textContent = text;
        a.applyAttributes(attrMap);
        return a;
    }

    /** Create an HTML input field, wrapped in an HTML label,
    * with the given attributes applied to it,
    * The returned HTML objects are extended by extendNode().
    * DOC-NONSPECIFIC
    */
    doc.createInputText = function(labelText, attrMap, defaultVal)
    {
        var span = this.createXElement("label");
        with (span) {
            if (labelText != null)
                appendChildText(labelText + ": ");
            var input = this.createXElement("input", attrMap);
            with (input) {
                type = "text";
                value = defaultVal;
            }
            appendChild(input);
        }
        return span;
    }

    doc.createTextArea = function(labelText, attrMap, defaultVal)
    {
        var span = this.createXElement("label");
        with (span) {
            if (labelText != null)
                appendChildText(labelText + ": ");
            var input = this.createXElement("textarea", attrMap);
            with (input) {
                value = defaultVal;
            }
            appendChild(input);
        }
        return span;
    }

    /** Create an HTML checkbox, wrapped in an HTML label,
    * with the given attributes applied to it,
    * The returned HTML objects are extended by extendNode().
    * DOC-NONSPECIFIC
    */
    doc.createCheckbox = function(labelText, attrMap, isChecked)
    {
        var span = this.createXElement("label");
        with (span) {
            var input = this.createXElement("input", attrMap);
            with (input) {
                type = "checkbox";
                checked = isChecked;
            }
            appendChild(input);
            appendChildText(labelText);
        }
        return span;
    }

    /** Create a set of HTML radio buttons, wrapped in an HTML label element.
    * The returned HTML objects are extended by extendNode().
    * DOC-NONSPECIFIC
    */
    doc.createRadioset = function(attrMap, optionMap, defaultKey)
    {
        var spanList = new Array();

        for (var key in optionMap)
        {
            var label = this.createXElement("label");
            with (label) {
                var input = this.createXElement("input", attrMap);
                with (input) {
                    type = "radio";
                    value = key;
                    if (key == defaultKey)
                        checked = true;
                }
                appendChild(input);
                appendChildText(optionMap[key]);
            }
            spanList.push(label);
        }
        return spanList;
    }

    /** Create an HTML select element, wrapped in an HTML label element.
    * The returned HTML objects are extended by extendNode().
    * DOC-NONSPECIFIC
    */
    doc.createSelect = function(labelText, attrMap, optionMap, defaultKey)
    {
        var span = this.createXElement("label");
        with (span) {
            if (labelText != null)
                appendChildText(labelText + ": ");
            var select = this.createXElement("select", attrMap);
            with (select)
            {
                for (var key in optionMap)
                {
                    var option = this.createXElement("option");
                    with (option) {
                        value = key;
                        if (key == defaultKey) {
                            selected = true;
                        }
                        appendChildText(optionMap[key]);
                    }
                    appendChild(option);
                }
            }
            appendChild(select);
        }
        return span;
    }

    /** Create a labeled/boxed area (eg, typical dialog box component).
    */
    doc.createTopicDiv = function(topicTitle, contextNode)
    {
        var shiftEms = ".7";
        basecolor = getBaseColor(contextNode);

        var frame_div = this.createXElement("div");
        with (frame_div) {
            with (style) {
                border = "1px solid gray";
                marginTop = (shiftEms * 1.5) + "em";
                marginLeft = "6px";
                marginRight = "6px";
                MozBorderRadius = "3px";
            }

            // superimposed title
            var title_span = this.createXElement("span");
            with (title_span.style) {
                position = "relative";
                top = -shiftEms + "em";
                fontSize = "10pt";
                color = "black";
                backgroundColor = basecolor;
                marginLeft = "6px";  // shift title right
                padding = "0px 4px 0px 4px"; // blot out frame on left & right
            }
            title_span.appendChildText(topicTitle);
            appendChild(title_span);

            // content area
            var content_div = this.createXElement("div");
            content_div.style.marginTop = -shiftEms + "em";
            content_div.style.padding = "6px";
            appendChild(content_div);
        }
        frame_div.contentElement = content_div;

        return frame_div;

        function getBaseColor(contextNode)
        {
            while (contextNode != null && contextNode.tagName != "BODY") {
                var c = contextNode.style.backgroundColor;
                if (c != "") {
                    return c;
                }
                contextNode = contextNode.parentNode;
            }
            return "white";
        }
    }

    /** Create a labeled/boxed area (eg, typical dialog box component).
    * DOC-CENTRIC
    */
    if (prefs.get("sendAnonymousStatistics")) {
        var counter_img = doc.createXElement("img");
        counter_img.id = "refactoror.net_counter";
        counter_img.src = "http://refactoror.net/spacer.gif?" + ident;
        doc.selectNode("//body").appendChild(counter_img);
    }

    return doc;
}

/** Extend the given node with methods
* for querying and modifying the node object.
*/
function extendNode(node)
{
    if (node == null)
        return null;

    /** Create an HTML element of the specified type,
    * with the given attributes applied to it.
    * The returned object is extended by extendNode().
    */
    node.createXElement = function(tagName, attrMap)
    {
        var node = extendNode(this.ownerDocument.createElement(tagName));
        this.applyAttributes(attrMap);
        return node;
    }

    // Selection methods that operate within the scope of this node

    node.selectValue        = function(xpath) { return document.selectValue(xpath, this); }
    node.selectNode         = function(xpath) { return document.selectNode(xpath, this); }
    node.selectNodeNullable = function(xpath) { return document.selectNodeNullable(xpath, this); }
    node.selectNodeSet      = function(xpath) { return document.selectNodeSet(xpath, this); }

    node.foreachNode = function(xpath, func) { document.foreachNode(xpath, func, this); }
    node.isolateNode = function(xpath) { document.isolateNode(xpath, this); }

    node.applyAttributes = function(attrMap) {
        for (var key in attrMap) {
            this[key] = attrMap[key];
        }
    }

    /** &nbsp;
    */
    node.NBSP = "\u00A0";

    /** Create a DOM object of the given type,
    * and append it to this node.
    */
    node.appendChildElement = function(tagName) {
        var newNode = this.createXElement(tagName);
        this.appendChild(newNode);
        return newNode;
    };

    /** Create a text node,
    * optionally wrapped in the given HTML element types,
    * and append it to this node.
    */
    node.appendChildText = function(text, spanList, attrMap)
    {
        var newNode = this.ownerDocument.createTextNode(text);
        // wrap with other elements, if any, (eg: ["b", "i"])
        if (spanList != null) {
            for (var i = spanList.length - 1; i >= 0; i--) {
                var n = this.createXElement(spanList[i]);
                n.appendChild(newNode);
                newNode = n;
            }
        }
        if (attrMap != null) {
            newNode.applyAttributes(attrMap);
        }
        this.appendChild(newNode);
        return newNode;
    };

    /** Create a text node consisting of a series of &nbsp; entities,
    * and append it to this node.
    */
    node.appendChildTextNbsp = function(count) {
        if (count == null)
            count = 1;
        var buf = "";
        for (i = 0; i < count; i++) {
            buf += this.NBSP;
        }
        this.appendChildText(buf);
    };

    /** Insert the given node as the first child of this node.
    */
    node.prependChild = function(newNode) {
        this.insertBefore(newNode, this.firstChild);
        return newNode;
    };

    /** Insert the given node in front of this node.
    */
    node.prependSibling = function(newNode) {
        var p = this.parentNode;
        p.insertBefore(newNode, this);
        return newNode;
    };

    /** Insert the given node after this node.
    */
    node.appendSibling = function(newNode) {
        var p = this.parentNode;
        var followingSibling = this.nextSibling;
        // what if null .. use p.appendChild ?
        p.insertBefore(newNode, followingSibling);
    };

    /** Create an HTML element of the specified type,
    * with the given attributes applied to it,
    * then move this node inside the newly created node,
    * and attach the newly created node in place of this node
    * returning the newly created object.
    */
    node.wrapIn = function(type, attrs) {
        var wrapperNode = this.createXElement(type, attrs);
        this.prependSibling(wrapperNode);
        this.remove();
        wrapperNode.appendChild(this);
        return wrapperNode;
    };

    /** 
    */
    node.makeCollapsible = function(id, isPersistent) {
        return new Collapsible(this, id, isPersistent);
    };

    /** Remove this node, and insert the given node in its place.
    */
    node.replaceWith = function(node) {
        this.appendSibling(node);
        this.remove();
        return node;
    };

    /** Create an HTML table row.
    */
    node.appendTableRow = function(valueList, tdAttrMapList)
    {
        var tr = this.createXElement("tr");
        for (var i in valueList)
        {
            var td = this.createXElement("td");
            if (tdAttrMapList != null)
                td.applyAttributes(tdAttrMapList[i]);
            if (valueList[i] == null)
                ;
            else if (typeof(valueList[i]) == "string")
                td.appendChild( this.ownerDocument.createTextNode(valueList[i]) );
            else
                td.appendChild( valueList[i] );
            tr.appendChild(td);
        }
        this.appendChild(tr);
    }

    /** Remove this node from the DOM.
    */
    node.remove = function() {
        this.parentNode.removeChild(this);
    }

    /** Hide this node.
    */
    node.hide = function() {
        this.style.display = "none";
    }

    /** Hide nodes that are siblings to this node.
    */
    node.hideSiblings = function() {
        this.foreachNode("../child::*", function(node) {
            if (! this.isSameNode(node)) {
                if (node.tagName != "SCRIPT" && node.tagName != "NOSCRIPT")
                    node.hide();
            }
        });
    };

    /** Show this node.
    */
    node.show = function() {
        this.style.display = null;
    }

    /** Calculate the absolute X position of this HTML element.
    */
    node.findPosX = function()
    {
        var x = 0;
        var node = this;
        while (node.offsetParent != null) {
            x += node.offsetLeft;
            node = node.offsetParent;
        }
        if (node.x != null)
            x += node.x;
        return x;
    }

    /** Calculate the absolute Y position of this HTML element.
    */
    node.findPosY = function()
    {
        var y = 0;
        var node = this;
        while (node.offsetParent != null) {
            y += node.offsetTop;
            node = node.offsetParent;
        }
        if (node.y != null)
            y += node.y;
        return y;
    }

    return node;
}


// ==================== TabSet object ====================

var activeTabsets = new Array();

// assumes that doc has already been extended
function TabSet(doc, tabsetId, tabLabels)
{
    this.doc = doc;
    this.tabsetId = tabsetId;
    this.tabLinkMap = new Array();
    this.tabDivMap = new Array();

    // save TabSet object reference for callbacks
    activeTabsets[tabsetId] = this;

    this.getTabContent_div = function(labelText) {
        return this.tabDivMap[labelText];
    }

    this.createTab = function(idx, labelText)
    {
        var a = this.doc.createXElement("a", {
            name: this.tabsetId,
            textContent: labelText,
            className: "DialogBox_clickable"
        });
        with (a.style) {
            padding = "3px 4px";
            border = "1px solid black";
            MozBorderRadius = "4px";
            borderBottom = "none";
            fontSize = "9pt";
            color = "black";
            textDecoration = "none";
        }
        return a;
    }

    this.activateTab = function(a) {
        with (a.style) {
            paddingTop = "4px";
            backgroundColor = "lightgray";
        }
        var content_div = this.getTabContent_div(a.textContent);
        content_div.show();
    }

    this.deactivateTab = function(a) {
        with (a.style) {
            paddingTop = "3px";
            backgroundColor = "darkgray";
        }
        var content_div = this.getTabContent_div(a.textContent);
        content_div.hide();
    }

    this.selectTab = function(selected_a)
    {
        // (can be called from outside this object's context, (ie, click listener))
        var tabset = activeTabsets[selected_a.name];
        // deselect all tabs
        tabset.doc.foreachNode("//a[@name='" + selected_a.name + "']", function(a) {
            tabset.deactivateTab(a);
        });
        // then select the clicked tab
        tabset.activateTab(selected_a);
    }

    this.initialize = function(labelText)
    {
        var maxX = 0;
        var maxY = 0;
        // determine largest width/height across content divs
        for (d in this.tabDivMap) {
            var div = this.tabDivMap[d];
            if (div.clientWidth  > maxX) maxX = div.clientWidth;
            if (div.clientHeight > maxY) maxY = div.clientHeight;
        }
        // equalize size of content divs to largest
        for (d in this.tabDivMap) {
            var div = this.tabDivMap[d];
            div.style.width = maxX;
            div.style.height = maxY;
        }
        // select the default tab
        if (labelText == null) {
            labelText = tabLabels[0];
        }
        this.selectTab(this.tabLinkMap[labelText])
    }


    this.container_div = doc.createXElement("div", { id: this.tabsetId });

    var ul = doc.createXElement("ul");
    this.container_div.appendChild(ul);
    with (ul.style) {
        margin = "13px 7px 1px 12px";
        padding = "0px 0px 0px 0px";
        fontSize = "10pt";
    }

    for (t in tabLabels)
    {
        var tab_a = this.createTab(t, tabLabels[t]);
        tab_a.addEventListener('click', function(event) {
                // now we're in the isolated context of the click
                // ie, context inferred from event & globals
                var selected_a = event.target;
                var tabset = activeTabsets[selected_a.name];
                tabset.selectTab(selected_a);
            },
            false
        );
        ul.appendChild(tab_a);
        this.tabLinkMap[tabLabels[t]] = tab_a;
        // corresponding content div
        var tabContent_div = doc.createXElement("div", {
            id: this.tabsetId + ":" + tabLabels[t]
        });
        with (tabContent_div.style) {
            margin = "0px 7px 0px 7px";
            padding = "4px 4px 4px 4px";
            border = "2px outset black";
        }
        this.container_div.appendChild(tabContent_div);
        this.tabDivMap[tabLabels[t]] = tabContent_div;
    }
}


// ==================== DialogBox object ====================

var activeDialogs = new Array();

// assumes that doc has already been extended
function DialogBox(doc, dialogTitle)
{
    this.doc = doc;
    this.callbacks = null;

    this.createDialog = function(popupName, dialogStyle, buttonDefs)
    {
        this.popupId = popupName + "_dialog";

        var main_div = this.doc.createXElement("div");
        with (main_div) {
            id = this.popupId;
            setAttribute("style", dialogStyle);
            style.maxWidth  = window.innerWidth - 50;
            style.maxHeight = window.innerHeight - 70;
            style.overflow = "auto";
            if (style.backgroundColor == "")
                style.backgroundColor = "white";

            // dialog box structure
            innerHTML =
                // border layers
                  '<div style="border: 1px solid; border-color: gainsboro darkslategray darkslategray gainsboro;">'
                + '<div style="border: 1px solid; border-color: white darkgray darkgray white;">'
                + '<div style="border: 2px solid gainsboro;">'
                // grid (has to be a table to acheive float behaviors)
                + '<table cellspacing="0" cellpadding="0">'
                + '<tbody>'
                // titlebar (optional)
                + ((dialogTitle != null) ?
                      '<tr id="' + this.popupId + '_titlebar"><td'
                    + ' style="padding: 2px; background-color: navy; color: white; font: bold 9pt Arial;"'
                    + '>' + dialogTitle
                    + '</td></tr>'
                    : "")
                // main content area
                + '<tr id="' + this.popupId + '_main" style="overflow: auto;"><td>'
                + '<div id="' + this.popupId + '_content"/>'
                + '</td></tr>'
                // button bar
                + '<tr id="' + this.popupId + '_buttons"><td style="padding: 6px;">'
                + '</td></tr>'
                + '</tbody>'
                + '</table>'
                + '</div>'
                + '</div>'
                + '</div>'
            ;
        }
        this.doc.selectNode("//body").appendChild(main_div);

        this.main_td = main_div.selectNodeNullable("//tr[@id='" + this.popupId + "_main']/td")
        var content_div = main_div.selectNode("//div[@id='" + this.popupId + "_content']");
        var buttonbar_td = main_div.selectNodeNullable("//tr[@id='" + this.popupId + "_buttons']/td")

        var controlButtons_span = this.doc.createXElement("center");

        if (buttonDefs != null)
        {
            this.callbacks = buttonDefs;
            for (b in buttonDefs)
            {
                var button = null;
                if (b == "X")
                {
                    var titlebar_td = main_div.selectNodeNullable("//tr[@id='" + this.popupId + "_titlebar']/td")
                    if (titlebar_td != null) {
                        // X close button in the right side of the titlebar
                        button = this.doc.createXElement("a");
                        with (button) {
                            id = this.popupId + "_closer";
                            href = "javascript:void(0)";
                            with (style) {
                                cssFloat = "right";
                                border = "1px solid";
                                borderColor = "white darkslategray darkslategray white";
                                backgroundColor = "lightgray";
                                padding = "0px 1px 0px 2px";
                                font = "bold 9pt Arial";
                                color = "black";
                                textAlign = "center";
                                lineHeight = "110%";
                            }
                            appendChildText("X");
                        }
                        titlebar_td.prependChild(button);
                    }
                    else {
                        // X close button in the upper-right of window
                        button = this.doc.createXElement("a");
                        with (button) {
                            id = this.popupId + "_closer";
                            href = "javascript:void(0)";
                            with (style) {
                                cssFloat = "right";
                                backgroundColor = "#AA0000";
                                padding = "2px";
                                font = "bold 8pt Arial";
                                textDecoration = "none";
                                color = "white";
                            }
                            appendChildText("X");
                        }
                        content_div.prependSibling(button);
                    }
                }
                else {
                    // a regular button at bottom of window
                    button = this.doc.createXElement("button");
                    with (button.style) {
                        margin = "0px 5px";
                        fontSize = "8pt";
                        fontFamily = "Helvetica, sans-serif";
                    }
                    controlButtons_span.appendChild(button);
                }

                with (button) {
                    name = this.popupId; // name attr associates callbacks with the dialog id
                    className = "DialogBox_clickable";
                    textContent = b;
                    addEventListener('click', function(event) {
                            // now we're in the isolated context of the click
                            // ie, context inferred from event & globals
                            var doc = extendDocument(event.target.ownerDocument);
                            var dialog = activeDialogs[event.target.name];
                            var popupId = event.target.textContent;
                            var callbackFunc = dialog.callbacks[popupId];
                            dialog.hidePopup();
                            callbackFunc(doc);
                            dialog.removePopup();
                        },
                        false
                    );
                }
            }
            buttonbar_td.appendChild(controlButtons_span);

            this.doc.addStyle(
                ".DialogBox_clickable:hover { cursor: pointer; }\n"
            );
        }

        // save DialogBox object reference for callbacks
        activeDialogs[this.popupId] = this;

        return content_div;
    }

    this.hidePopup = function() {
        var div = this.doc.getElementById(this.popupId);
        div.style.visibility = "hidden";
    }

    this.removePopup = function() {
        var div = this.doc.getElementById(this.popupId);
        div.parentNode.removeChild(div);

        activeDialogs[this.popupId] = null;
    }
}

function noop() {
}


// ==================== Preferences object ====================

/** (This object is created before the Logger object,
 * therefore the log methods cannot be used. Use GM_log instead.)
*/
function Preferences(defaultValuesMap)
{
    this.defaultValuesMap = defaultValuesMap;
    this.cacheMap = new Object();

    this.get = function(prefName)
    {
        var value = this.cacheMap[prefName];
        if (typeof(value) == "undefined")
        {
            value = GM_getValue(prefName);
            if (typeof(value) == "undefined")
            {
                value = this.defaultValuesMap[prefName];
                if (typeof(value) == "undefined") {
                    log.error("Unmanaged preference: " + prefName);
                    return value;
                }
            }
            this.set(prefName, value);
        }
        return value;
    }

    this.set = function(prefName, prefValue)
    {
        GM_setValue(prefName, prefValue);
        this.cacheMap[prefName] = prefValue;
    }

    this.getAsList = function(prefName, delim, wrapperType)
    {
        var value = this.get(prefName);
        if (value != null) {
            valueList = value.split(delim);
        }
        else {
            valueList = new Array();
        }

        if (wrapperType != null) {
            // wrap elements in custom object type
            var wrappedValueList = new Array();
            for (i in valueList) {
                wrappedValueList[i] = new wrapperType(valueList[i]);
            }
            return wrappedValueList;
        }

        // add utility methods to the resulting Array object

        valueList.contains = function(matchText)
        {
            if (matchText == null) {
                log.error("a null arg: " + this + " " + matchText);
                return false;
            }

            for (var i in this) {
                if (matchText == this[i])
                    return true;
            }
            return false;
        }

        return valueList;
    }
}


// ==================== PreferencesManager object ====================

function setScreenPosition(node, posIndicator)
{
    with (node.style)
    {
        position = "fixed";
        zIndex = 999;
        switch (posIndicator) {
            case "TL": top = 0;    left = 0;  break;
            case "TR": top = 0;    right = 0; break;
            case "BL": bottom = 0; left = 0;  break;
            case "BR": bottom = 0; right = 0; break;
            default:
                log.error("Unrecognized menu position indicator: " + menuPos);
        }
    }
}

function PreferencesManager(doc, uniqId, title, buttonDefs)
{
    this.doc = extendDocument(doc);
    this.uniqId = uniqId;
    this.dialogBox = new DialogBox(this.doc, title);
    this.buttonDefs = buttonDefs;

    /** Display the Preferences dialog.
    */
    this.open = function()
    {
        if (this.doc.selectNodeNullable("//div[@id='" + this.uniqId + "_dialog']")) {
            log.info("Preferences dialog already open");
            return null;  // the dialog is already open
        }

        var dialogBox_div = this.dialogBox.createDialog(
            this.uniqId,
            "z-index: 999; left: 15%; top: 25px; position: fixed;"
            + " background-color: lightgray;",
            this.buttonDefs
        );
        with (dialogBox_div.style) {
            fontSize = "10pt";
            fontFamily = "Arial, Helvetica, sans-serif";
            overflow = "auto";
            backgroundColor = "lightgray";
        }

        return dialogBox_div;
    }

    /** Create an HTML input element associated with the named greasemonkey preference.
    */
    this.createPreferenceInput = function(prefName, titleText, tipText, attrMap, optionMap)
    {
        var prefValue = prefs.get(prefName);
        var item_label;
        var inputTagname = "input";
        switch (typeof(prefValue)) {
            case "boolean":
                item_label = this.doc.createCheckbox(titleText, attrMap, prefValue);
                break;
            case "string":
            case "number":
                if (optionMap != null) {
                    item_label = this.doc.createSelect(titleText, attrMap, optionMap, prefValue);
                    inputTagname = "select";
                }
                else if (attrMap["rows"] != null) {
                    item_label = this.doc.createTextArea(titleText, attrMap, prefValue);
                    inputTagname = "textarea";
                }
                else {
                    item_label = this.doc.createInputText(titleText, attrMap, prefValue);
                }
                break;
            default:
                log.warn("For " + prefName + ", unrecognized type: " + typeof(prefValue));
        }
        item_label.style.fontSize = "9pt";
        if (tipText != null)
            item_label.title = tipText;
        with (item_label.selectNode(inputTagname)) {
            name = prefName;
            className = "preferenceSetting";
            applyAttributes(attrMap);
        }
        return item_label;
    }

    this.createScreenCornerPreference = function(prefName)
    {
        var prefValue = prefs.get(prefName);

        var table = this.doc.createXElement("table", {
            id: prefName + "_2x2"
        });
        with (table) {
            style.borderCollapse = "collapse";
            cellPadding = 0; cellSpacing = 0;

            appendTableRow([ createRadioButton("TL"), null, createRadioButton("TR") ]);
            appendTableRow([          null,           null,          null           ]);
            appendTableRow([ createRadioButton("BL"), null, createRadioButton("BR") ]);

            style.border = "3px inset black";
            foreachNode(".//input", function(inp) {
                inp.style.margin = "0px";
            });
            with (selectNode(".//tr[2]/td[2]")) {
                // acheive roughly 4/3 aspect ratio
                style.width = "14px";
                style.height = "4px";
            };
        }
        return table;

        function createRadioButton(choiceValue)
        {
            var radio_input = doc.createXElement("input", {
                type: "radio", name: prefName, value: choiceValue,
                className: "preferenceSetting"
            });
            if (choiceValue == prefValue) {
                radio_input.checked = true;
            }
            return radio_input;
        }
    }

    /** Store current screen values into the associated Preferences,
    * but only for values that have changed.
    * (This is the primary logic for the OK button)
    */
    this.storePrefs = function()
    {
        this.doc.foreachNode("//*[@class='preferenceSetting']", function(inputObj) {
            var prefName = inputObj.name;
            var prefValue;
            if (inputObj.type == "checkbox") {
                prefValue = inputObj.checked;
            }
            else if (inputObj.type == "radio") {
                if (inputObj.checked)
                    prefValue = inputObj.value;
                else
                    return; // skip all in group except the checked one
            }
            else {
                prefValue = inputObj.value;
            }

            var oldValue = GM_getValue(prefName, prefValue);
            if (prefValue != oldValue)
            {
                var defaultValue = prefs.get(prefName);
                if (typeof(defaultValue) == "number") {
                    if (isNaN(prefValue)) {
                        alert("Non-numeric value '" + prefValue + "' is invalid for preference " + prefName);
                        return false; // continue on to next preference item
                    }
                    prefValue = parseFloat(prefValue);
                }
                if (typeof(prefValue) == "string")
                    log.info("Setting preference: " + prefName + " => '" + prefValue + "'");
                else
                    log.info("Setting preference: " + prefName + " => " + prefValue);
                prefs.set(prefName, prefValue);
            }
        });
    }
}


// ==================== Collapsible object ====================

function Collapsible(theNode, collapserId, isPersistent)
{
    this.node = theNode;
    this.doc = extendDocument(theNode.ownerDocument);

    if (collapserId == null) {
        if (theNode.id == null)
            collapserId = "collapser_" + generateUuid();
        else
            collapserId = theNode.id + "_collapser";
    }

    // maintain object reference(s) for callbacks
    if (document.activeCollapsers == null) {
        document.activeCollapsers = new Object();
    }
    document.activeCollapsers[collapserId] = this;

    this.expand = function(event) {
        collapsible = this;
        if (event != null) {
            var collapserId = event.target.parentNode.id;
            collapsible = document.activeCollapsers[collapserId];
            if (isPersistent) {
                prefs.set(collapserId, true);
            }
        }
        collapsible.node.show();
        collapsible.expander.hide();
        collapsible.collapser.show();
    }

    this.collapse = function(event) {
        collapsible = this;
        if (event != null) {
            var collapserId = event.target.parentNode.id;
            collapsible = document.activeCollapsers[collapserId];
            if (isPersistent) {
                prefs.set(collapserId, false);
            }
        }
        collapsible.node.hide();
        collapsible.collapser.hide();
        collapsible.expander.show();
    }

    this.createController = function(func, base64) {
    	var img = this.doc.createXElement("img");
//     	img.title = label;
    	img.src = 'data:image/gif;base64,' + base64;
        img.addEventListener('click', func, false);

        with (img.style) {
            cssFloat = "left";
            left = "0px";
            position = "absolute";
            zIndex = 999;
        }
        return img;
    }

    var span = this.doc.createXElement("span", { id: collapserId });
    this.node.prependSibling(span);

    this.expander = this.createController(this.expand,
    	'R0lGODlhEAAQAKEDAAAA/wAAAMzMzP///yH5BAEAAAMALAAAAAAQABAAAAIhnI+pywOtwINHTmpvy3rx' +
    	'nnABlAUCKZkYoGItJZzUTCMFACH+H09wdGltaXplZCBieSBVbGVhZCBTbWFydFNhdmVyIQAAOw=='
    );
    span.appendChild(this.expander);

    this.collapser = this.createController(this.collapse,
		'R0lGODlhEAAQAKEDAAAA/wAAAMzMzP///yH5BAEAAAMALAAAAAAQABAAAAIdnI+py+0Popwx0RmEuiAz' +
		'6jVS6HTaY5zoyrZuWwAAIf4fT3B0aW1pemVkIGJ5IFVsZWFkIFNtYXJ0U2F2ZXIhAAA7'
    );
    span.appendChild(this.collapser);

    var isExpanded = false;
    if (isPersistent) {
        isExpanded = prefs.get(collapserId);
    }

    if (isExpanded)
        this.expand()
    else
        this.collapse()
}


// ==================== DocumentContainer object ====================

/** Create and manage invisible iframe content loaded from an arbitrary URL.
* If the same URL is requested more than once, it is returned from cache.
* Example:
*    var dc = new DocumentContainer();
*    dc.loadFromSameOrigin("search.do?category=eligible",
*        function(doc) {
*            if (selectNode("//text()='Dilbert'", doc))
*                alert("Hide your daughters!");
*        }
*    );
*/
function DocumentContainer(debugFlag)
{
    var iframeCache = new Array();
    this.debug = debugFlag;

    this.loadFromSameOrigin = function(theUrl, theFunc)
    {
        var iframe = iframeCache[theUrl];
        if (iframe != null) {
            if (theFunc != null)
                theFunc(iframe.contentDocument);
            return;
        }

        var iframe = this.attachIframe(theUrl);

        // wait for the DOM to be available, then dispatch
        iframe.addEventListener(
            "load",
            function(evt) {
                var theIframe = evt.currentTarget;
                var therUrl = theIframe.contentWindow.location.href;
                iframeCache[theUrl] = theIframe;
                if (theFunc != null)
                    theFunc(theIframe.contentDocument);
            },
            false
        );

        // load the content
        iframe.contentWindow.location.href = ajaxstaticUrl(theUrl);
    }

    this.loadFromForeignOrigin = function(theUrl, theFunc)
    {
        if (window != top) {
            return;  // prevent infinite recursion
        }
        var iframe = this.attachIframe(theUrl);

        GM_xmlhttpRequest(
        {
            method: "GET",
            url: ajaxstaticUrl(theUrl),
            onload: function(details) {

                // give it a URL so that it will create a .contentDocument property.
                // Make it the same as the current page,
                // Otherwise, same-origin policy would prevent us.
                iframe.contentWindow.location.href = "http://tv.yahoo.com/";

                // wait for the DOM to be available, then dispatch
                iframe.addEventListener(
                    "DOMContentLoaded",
                    function() {
                        if (theFunc != null)
                            theFunc(iframe.contentDocument);
                    },
                    false
                );

                // write the received content into the document
                iframe.contentDocument.open("text/html");
                iframe.contentDocument.write(details.responseText);
                iframe.contentDocument.close();
            }
        });

        return iframe.contentDocument;
    }

    this.attachIframe = function(theUrl)
    {
        // create an IFRAME element to write the document into.
        // It must be added to the document and rendered (eg, display != none)
        // to be properly initialized.
        var iframe = document.createElement("iframe");
        iframe.id = "DocumentContainer_" + theUrl;
        if (this.debug == null) {
            iframe.width = 0;
            iframe.height = 0;
            iframe.style.visibility = "hidden";
        }
        else {
            iframe.width = 800;
            iframe.height = 700;
        }
        document.body.appendChild(iframe);

        iframe.contentWindow.location.href = "about:blank";

        return iframe;
    }

    // private helper methods

    /** Add param to URL marking it as not to be re-processed.
    */
    function ajaxstaticUrl(theUrl)
    {
        var newUrl = theUrl;
        if (newUrl.indexOf("?") == -1)
            newUrl += "?";
        if (newUrl.indexOf("?") != newUrl.length-1)
            newUrl += "&";
        return newUrl + "ajaxstatic";
    }
}

/** retrieve each document specified in the urlList
* invoking onloadFunc with each doc,
* and then finally invoking onrendezvousFunc with the assembled list of docs
*/
function withDocuments(urlList, onloadFunc, onrendezvousFunc)
{
    var context = new Object();
    context.resultDocList = new Array();
    context.pendingCount = urlList.length;

    for (var u in urlList)
    {
        var dc = new DocumentContainer();
        dc.loadFromSameOrigin(urlList[u], function(curDoc)
        {
            var xdoc = extendImdbNameDocument(curDoc);

            if (onloadFunc != null) {
                onloadFunc(xdoc);
            }
            if (--context.pendingCount == 0) {
                if (onrendezvousFunc != null) {
                    context.resultDocList.push(xdoc);
                    onrendezvousFunc(context.resultDocList);
                }
            }
        });
    }
}

/** Recursively retrieve each document specified in the urlList,
* then invoke the dispatch function with the list of loaded docs.
*/
function withDocumentsSerialized(urlList, func, docList)
{
    var curUrl = urlList.shift();
    if (docList == null)
        docList = new Array();

    var dc = new DocumentContainer();
    dc.loadFromSameOrigin(curUrl, function(curDoc)
    {
        docList.push( extendImdbNameDocument(curDoc) );
        if (urlList.length > 0)
            withDocuments(urlList, func, docList);
        else
            func(docList);
    });
}



// ==================== Logger object ====================

function Logger()
{
    this.logLevels = ["ERROR", "WARN", "INFO", "DEBUG"];

    this.level = null;

    this.setLevel = function(level) {
        this.level = level;
        if (level >= 2)
            GM_log("=== LOGGER LEVEL: " + this.logLevels[this.level] + " ===");
    }

    this.setLevel(arrayIndexOf(this.logLevels, prefs.get("loggerLevel")));

    this.error = function(msg) { if (this.level >= 0) GM_log("ERROR: " + msg); }
    this.warn  = function(msg) { if (this.level >= 1) GM_log("WARN: " + msg); }
    this.info  = function(msg) { if (this.level >= 2) GM_log("INFO: " + msg); }
    this.debug = function(msg) { if (this.level >= 3) GM_log("DEBUG: " + msg); }

    this.getLogLevelMap = function() { return IdentityMapForArray(this.logLevels); };
}


// ==================== StopWatch object ====================

function StopWatch(desc)
{
    this.desc = desc;
    this.startTime = new Date();
    this.prevTime = this.startTime;

    this.lap = function(cmt) {
        var curTime = new Date();
        var lapTime = curTime - this.prevTime;
        var cumTime = curTime - this.startTime;
        var desc = this.desc;
        if (cmt != null)
            desc += " " + cmt;
        log.debug(lapTime + "ms, (cumulative " + cumTime + "ms); " + desc);
        this.prevTime = new Date();
    }
}


// ==================== JavaScript object extenstions ====================

function extendJavascriptObjects()
{
    // ---------- String extensions ----------

    /** Format text content as it will appear on a page (before wrapping, etc).
    */
    String.prototype.normalizeWhitespace = function()
    {
        text = this.replace(/\s+/g, " ");          // reduce internal whitespace
        text = text.replace(/ ([,;:\.!])/g, "$1"); // snug-up punctuation
        return text.trimWhitespace();
    }

    /** Format text content as it will appear on a page (before wrapping, etc).
    */
    String.prototype.trimWhitespace = function()
    {
        return this.replace(/^\s*/, "").replace(/\s*$/, "");
    }

    String.prototype.stripQuoteMarks = function()
    {
        text = this.replace(/"/g, "");
        return text;
    }

    // ---------- Date extensions ----------

    SECOND = 1000;
    MINUTE = SECOND * 60;
    HOUR = MINUTE * 60;
    DAY = HOUR * 24;
    WEEK = DAY * 7;

    // Example, on the hour: floor(Date.HOUR)
    Date.prototype.floor = function(unit) {
        var floorMilli = Math.floor(this.getTime() / unit) * unit;
        return new Date(floorMilli);
    }

    Date.prototype.add = function(millis) {
        return new Date(this.getTime() + millis);
    }
}


// ---------- Array helpers ----------

function arrayIndexOf(theList, value, attrName)
{
    if (attrName == null) {
        // by element value
        for (var i in theList) {
            if (theList[i] == value)
                return i;
        }
    }
    else {
        if (typeof(value) == "object") {
            // by corresponding attribute in value array
            for (var i in theList) {
                if (theList[i][attrName] == value[attrName])
                    return i;
            }
        }
        else {
            // by attribute value
            for (var i in theList) {
                if (theList[i][attrName] == value) {
                    return i;
                }
            }
        }
    }
    return null;
}

function sortBy(theList, fieldList)
{
    theList.sort( function(a, b)
    {
        for (var i in fieldList) {
            if (a[fieldList[i]] < b[fieldList[i]]) return -1;
            if (a[fieldList[i]] > b[fieldList[i]]) return 1;
        }
        return 0;
    });
    return theList;
}

function sortDescBy(theList, fieldList)
{
    theList.sort( function(a, b)
    {
        for (var i in fieldList) {
            if (a[fieldList[i]] > b[fieldList[i]]) return -1;
            if (a[fieldList[i]] < b[fieldList[i]]) return 1;
        }
        return 0;
    });
    return theList;
}

function numericComparatorAsc(a, b) {
    return (a-b);
}

function numericComparatorDesc(a, b) {
    return (b-a);
}

/** .
*/
function IdentityMapForArray(ary)
{
    var map = new Array();
    for (i in ary) {
        map[ary[i]] = ary[i];
    }
    return map;
}

/** Create a new Array with pre-defined numeric indices,
* (ie, ready for inserts to random indices).
*/
function initArrayIndices(count) {
    var a = new Array(count);
    for (var i = 0; i < count; i++) {
        a[i] = null;
    }
    return a;
}


/** Dispatch processing for each grouping of elements based upon the named field.
*/
function foreachGrouping(theList, attrName, func)
{
    var curList = new Array();
    var prevValue = null;
    for (var i in theList)
    {
        if (theList[i][attrName] != prevValue)
        {
            if (curList.length > 0) {
                func(curList);
            }
            curList = new Array();
        }
        curList.push(theList[i]);
        prevValue = theList[i][attrName];
    }
}


// ==================== UrlParser object ====================

/** Parsing and formatting of URLs.
* url, params; scheme, host, port, path
*/
function UrlParser(urlString)
{
    var urlParts = urlString.split("?");
    this.url = urlParts[0];
    this.parms = new Array();

    // parse query params into name/value associative list
    if (urlParts[1]) {
        var queryItems = urlParts[1].split("&");

        for (var i in queryItems) {
            var parm = queryItems[i].split("=");
            this.parms[unescape(parm[0])] = unescape(parm[1]);
            // convert to numeric if appropriate
            var num = parseInt(parm[1]);
            if (!isNaN(num) && parm[1].substring(0, 1) != "0") {
                this.parms[unescape(parm[0])] = num;
            }
        }
    }

    // parse http://domain/path into scheme, domain, path
    this.url.match(/(\w+):\/\/([\w\.]+)(\/.*)/);
    this.scheme = RegExp.$1;
    this.host = RegExp.$2;
    this.path = RegExp.$3;

    // METHODS

    // assemble the query part of the URL
    this.getQuery = function()
    {
        queryItems = new Array();
        for (var p in this.parms) {
            if (this.parms[p])
                queryItems.push(escape(p) + "=" + escape(this.parms[p]));
        }
        if (queryItems.length == 0) {
            return "";
        }
        else {
            return "?" + queryItems.join("&");
        }
    }

    // assemble the whole URL
    this.toString = function()
    {
        return this.url + this.getQuery();
    }
}


// --------------- helper functions ---------------

/** Lookup preference setting and conditionally execute with error handling.
*/
function dispatchFeature(feaureName, func)
{
    if (prefs.get(feaureName))
    {
        tryCatch("feature: " + feaureName, func);
    }
}

/** Provide debug info if function throws an exception.
*/
function tryCatch(desc, func)
{
    try { func(); }
    catch(err) {
        log.error(
            "exception @ " + err.lineNumber + " [" + desc + "]" + " : " + err + "\n"
            + genStackTrace(arguments.callee)
        );
    }
}

// --------------- Stack Trace ---------------

function genStackTrace(func)
{
    var depthLimit = 20;
    var stackTrace = "Stack trace:\n";
    while (func != null) {
        if (--depthLimit < 0) {
            stackTrace += "more ...\n";
            break;
        }
        stackTrace += "called by: " + getFunctionSignature(func) + "\n";
        // TBD: line# within func
        func = func.caller;
    }

    return stackTrace + "\n\n";
}

function getFunctionSignature(func)
{
    var signature = getFunctionName(func);
    signature += "(";
    for (var i = 0; i < func.arguments.length; i++)
    {
        // trim long arguments
        var nextArgument = func.arguments[i];
        if(nextArgument.length > 30)
            nextArgument = nextArgument.substring(0, 30) + "...";

        // apend the next argument to the signature
        signature += "'" + nextArgument + "'";

        // comma separator
        if (i < func.arguments.length - 1)
            signature += ", ";
    }
    signature += ")";

    return signature;
}

function getFunctionName(func)
{
    // mozilla makes it easy
    if(func.name != null) {
        return func.name;
    }

    // try to parse the function name from the defintion
    var definition = func.toString();
    var name = definition.substring(
        definition.indexOf('function') + 8,
        definition.indexOf('(')
    );
    if (name != null)
        return name;

    // sometimes there won't be a function name (eg, dynamic functions)
    return "anonymous";
}