FR Tree Viewer

Brings together comments and their replies.

// ==UserScript==
// @name           FR Tree Viewer
// @namespace      http://cynwoody.appspot.com/fr_tree_viewer.html
// @description    Brings together comments and their replies.
// @date           2015-11-16
// @include        http://*.freerepublic.com*/posts*
// @include        http://freerepublic.com*/posts*
// @version 0.0.1.20151118234707
// ==/UserScript==

const STYLE = '.a2 {clear:both}' +
              '.postBox {' +
                  'padding:3px;' +
                  'border:1px solid #888;' +
                  'margin-top:3px;' +
                  'margin-bottom:3px}' +
              '.quoteButton {color:#009;margin-left:3px;padding:0px}' +
              '.quoteBox {background:#ffc;padding:3px;border:1px solid #ccf}' +

              '#progressBox {position:fixed;left:2px;top:10px;' +
                  'background:#fc0;padding:3px;border:1px solid black}' +
              '#logBox {background:skyblue;padding:3px}' +
              '#scrollBox {position:fixed;top:0px;left:0px;z-index:1;' +
                     'border:1px solid #888;background:yellow}' +
              '#scrollButtons {padding:2px}' +
              '#scrollButtons input {padding:0px;margin:0px;color:blue}' +
              '#scrollClose, #scrollClose input {' +
                  'margin:0px;padding:0px;text-align:right;' +
                  'color:red;font-size:7px;font-weight:bold}';

const INDENT = 7;       // Indentation per reply level, in pixels
const MAX_INDENT = 0.7; // Maximum indent, as a fraction of window width
const BACKGROUNDS = ['#ccf', '#ffc', '#cfc', '#fcc', '#cff', '#fcf', '#ccc'];

const SCROLL = {
    method: "constantTime",     // Change to "jump" to eliminate animation
    constantSpeed: {
        pixelsPerInterval: 50,
        timeInterval: 10
    },
    constantTime: {
        intervalCount: 25,
        timeInterval: 20
    }
};

var posts;              // Maps postNumber to post (as HTML text)
var replies;            // Maps postNumber to a list of replying post numbers
var postCheck;          // Cross-check. Used to pick up deleted posts
var maxIndent;          // Maximum indent, in pixels
var indexingCanceled;   // Set when user cancels indexing of posts
var selection;          // Text selected when Quote button pressed

// Keystroke watcher. Implements keyboard shortcuts:
//    Ctrl-Alt-T => Tree View
//    Ctrl-Alt-R => Poster Report

function onKeyPress(event)
{
    if (event.ctrlKey && event.altKey) {
        switch (event.charCode) {
            case 116: // t in Firefox
            case 20:  // t in Chrome
                onTreeViewClick(event, $('treeButton'));
                break;
            case 114: // r in Firefox
            case 18:  // r in Chrome
                onPosterReportClick(event, $('posterReport'));
                break;
        }
    }
}

// Handles Tree View button click (or Ctrl-Alt-T).

function onTreeViewClick(event, button)
{
    button = button || this;
    if (button.disabled)
        return;
    var callback = /Tree/.test(button.value) ? makeTreeView : makeFlatView;
    disable(button, 'Waiting ...');
    disable('posterReport');
    indexThread(button, callback);
}

// Rearranges the posted comments into the tree view, assuming the
// thread has been indexed successfully (ok == true).

function makeTreeView(ok)
{
    if (!ok) {
        enable('treeButton', 'View as Tree');
        enable('posterReport');
        return;
    }
    var start = new Date();
    var div = deleteExistingPosts();
    maxIndent = window.innerWidth * MAX_INDENT;
    postCheck = {count:0};
    addReply(div, 1, 0);
    if (postCheck.count < posts.length-1)
        addDeletedPosts(div);
    if (postCheck.count != posts.length-1)
        alert('postCheck = ' + postCheck.count + ', but there are ' +
                      (posts.length-1) + ' posts.');
    doFixups();
    addScrollBox();
    enable('treeButton', 'Flat View');
    enable('posterReport');
    logTime(start, 'generate the tree view');
}
makeTreeView.title = 'tree view';

// Makes a second pass over the posts to pick up deleted posts and
// their replies.  These would otherwise be missed, because deleted
// posts have no To link.

function addDeletedPosts(container)
{
    for (var x=1, limit=posts.length; x<limit; ++x) {
        if (!postCheck[x])
            addReply(container, x, 0);
    }
}

// Adds a post to the display with the indentation indicated by
// depth. Then calls itself to add each reply at the next deeper
// indentation level.

function addReply(container, postNumber, depth)
{
    ++postCheck.count;
    postCheck[postNumber] = true;
    var div = document.createElement('div');
    div.className = 'postBox';
    var indent = depth * INDENT;
    if (indent > maxIndent)
        indent = maxIndent;
    div.style.marginLeft = indent + 'px';
    div.style.background = BACKGROUNDS[depth % BACKGROUNDS.length];
    div.innerHTML = posts[postNumber];
    var anchor = document.createElement('a');
    anchor.name = postNumber;
    container.appendChild(anchor);
    container.appendChild(div);
    var replyList = replies[postNumber];
    if (replyList) {
        ++depth;
        for (var x=0, limit=replyList.length; x<limit; ++x)
            addReply(container, replyList[x], depth);
    }
}

// Rearranges the posts into the chronological flat view, similar to
// FR's normal view, but always showing all the posts.

function makeFlatView(ok)
{
    if (!ok) {
        enable('treeButton', 'View as Tree');
        enable('posterReport');
        return;
    }
    var start = new Date();
    var div = deleteExistingPosts();
    var s = '';
    for (var postNumber=1, limit=posts.length; postNumber<limit; ++postNumber) {
        s += '<a name=' + postNumber + '></a>\n';
        s += posts[postNumber];
        s += '<hr size=1 noshade=noshade>\n';
    }
    div.innerHTML = s;
    removeScrollBox();
    doFixups();
    enable('treeButton', 'Tree View');
    enable('posterReport');
    logTime(start, 'generate the flat view');
}
makeTreeView.title = 'flat view';

// Performs fixups needed after view creation (tree or flat).

function doFixups()
{
    fixLinks(document.body);
    removeBlankLines(document.body);
    addQuoteButtons(document.body);
    localizeDates(document.body);
    decrudify();
}

// Rewrites all the To-links in the node in such a was as to ensure
// they are valid in the new, single-page environment.

function fixLinks(node)
{
    var links = $x('.//a[contains(., "To ")]', node);
    for (var x=0, limit=links.snapshotLength; x<limit; ++x) {
        var link = links.snapshotItem(x);
        link.href = link.hash;
    }
}

// Removes the existing posts and replaces them with an empty div
// ready to receive the rearranged posts. Returns the empty div.

function deleteExistingPosts()
{
    var firstNode = findBeginningOfPosts();
    var lastNode = document.body.lastChild;
    do {
        lastNode = lastNode.previousSibling;
    } while (!/Disclaimer/.test(lastNode.innerHTML));
    var range = document.createRange();
    range.setStartAfter(firstNode);
    range.setEndBefore(lastNode);
    range.deleteContents();
    range.detach();
    var div = document.createElement('div');
    div.id = 'posts';
    document.body.insertBefore(div, lastNode);
    return div;
}

// Locates the node before the first post. If the user is signed in,
// we can use the 'comment' anchor. Otherwise, we have to find the
// actual first post and back up to the horizontal rule.

function findBeginningOfPosts()
{
    var firstNode = document.anchors.namedItem('comment');
    if (firstNode)
        return firstNode;
    var node = $xFirst('div[@class="b2"]');
    node = node || $xFirst('div[@id="posts"]');
    while (node) {
        node = node.previousSibling;
        if (node.tagName == 'HR')
            return node;
    }
    alert("Can't find posts!");
    return null;
}

// Parses all the posts in the thread and builds two tables (unless
// they already exist):
//    - posts, an array that maps a postNumber to the post's HTML
//      snippet.
//    - replies, a hash that links a postNumber to an array of
//      replying postNumbers.
// Calls the callback function when the indexing is complete (it will
// happen asynchronously if page fetches are required).

function indexThread(button, callback)
{
    if (replies)
        callback(true);
    else {
        var first = $xFirst('a[@class="fr_page_goto"][contains(., "first")]');
        if (first) {
            button.value = 'Waiting ...';
            indexWholeThread(first, callback);
        }
        else {
            var start = new Date();
            indexPosts(originalHTML);
            indexReplies();
            var now = new Date();
            logTime(start, 'index the thread');
            callback(true);
        }
    }
}

// Called by indexThread to index multipage threads. Fetches the entire
// thread in the background, adding the posts of each page to the posts
// index. Then indexes the replies and calls the callback function,
// indicating whether the operation completed or was canceled by the
// user.

function indexWholeThread(firstLink, callback)
{
    var start = new Date();
    var link = document.createElement('a');
    link.href = firstLink.href;
    link.hash = '';
    loadLink(link, loadNext);

    function loadNext(html)
    {
        if (indexingCanceled) {
            indexingCanceled = posts = replies = null;
            hideProgress();
            log('Loading and indexing canceled!');
            callback(false);
            return;
        }
        if (!html) {
            callback(false);
            return;
        }
        var r = html.match(/href="posts(\?[^"]*)" class="fr_page_goto"[^>]*>next</);
        if (r) {
            link.search = r[1];
            loadLink(link, loadNext);
        }
        else {
            showProgress('Generating ' + callback.title + ' ...');
            var postCount = posts.length - 1;
            indexReplies();
            var now = new Date();
            logTime(start, 'load and index');
            callback(true);
            hideProgress();
        }
    }
}

// Displays a message in floating box to let the user know how far a
// multipage indexing operation has proceeded. The box includes a Cancel
// button, in case the user decides to bail.

function showProgress(msg)
{
    var progress = $('progressMsg');
    if (!progress)
        progress = makeProgressBox();
    progress.innerHTML = msg;
    $('indexingCancel').disabled = false;
    $('progressBox').style.display = 'block';
}

function makeProgressBox()
{
    var div = document.createElement('div');
    div.id = 'progressBox';
    div.innerHTML = '<span id=progressMsg></span> ' +
              '<input id=indexingCancel type=button value=Cancel>';
    document.body.appendChild(div);
    $('indexingCancel').addEventListener('click', onProgressCancel, false);
    return $('progressMsg');
}

function hideProgress()
{
    var box = $('progressBox');
    if (box)
        box.style.display = 'none';
}

// Handles a progress box cancel click. Signals the indexing to stop.

function onProgressCancel()
{
    indexingCanceled = true;
    $('indexingCancel').disabled = true;
}

// Fetches a page in the background, indexes it, and calls the callback,
// passing the retrieved HTML.

function loadLink(link, callback)
{
    var req = new XMLHttpRequest();
    req.open('GET', link, true);
    req.onreadystatechange = handler;
    req.send(null);
    showProgress('Loading ' + link);

    function handler()
    {
        if (req.readyState == 4) {
            var html = req.responseText;
            if (req.status != 200) {
                var msg = "XHR received " + req.status + ' ' + req.statusText +
                        ' loading ' + link + '.';
                alert(msg);
                log(msg);
                html = null;
                indexingCanceled = true;
            }
            if (html)
                indexPosts(html);
            callback(html);
        }
    }
}

// Extracts the posts from a page of HTML text and adds them to the
// posts array, using postNumber as the subscript.

function indexPosts(html)
{
    var r = new RegExp('<a name="(\\d+)"></a>\\n([\\s\\S]+?' +
                       '(?:<div class="n2">[\\s\\S]+?</div>|' +
                       'Moderator</i></small><br[ /]*>))', 'gi');
    posts = posts || [];
    var match;
    while (match = r.exec(html))
        posts[match[1]] = match[2];
}

// Runs thru the posts array and builds the replies table. The replies
// table contains an array of replying post numbers for each post that
// has at least one reply.

function indexReplies()
{
    replies = {};
    for (var postNumber=1, limit=posts.length; postNumber<limit; ++postNumber) {
        var m = /<a .*?href=".*?#(\d+)">To \1</i.exec(posts[postNumber]);;
        if (m && m[1]) {
            var toNumber = m[1];
            var replyList = replies[toNumber];
            if (replyList)
                replyList.push(postNumber);
            else
                replies[toNumber] = [postNumber];
        }
    }
}

// Adds a draggable floating box which appears when the user clicks in
// in the white indentation space of the tree view. The box includes
// buttons to scroll up or down to the next post at or above the box's
// indent level.

function addScrollBox()
{
    var div = document.createElement('div');
    div.id = 'scrollBox';
    div.style.display = 'none';
    div.innerHTML = '<div id=scrollClose>' +
        '<input id=scrollCloseButton type=button value=x></div>' +
        '<div id=scrollButtons>' +
            '<input id=up type=button value=Up><br>' +
            '<input id=dn type=button value=Dn>' +
        '</div>';
    div.title = 'Scrolls up or down to next comment of same or outer color. ' +
                'Drag to change color.';
    document.body.appendChild(div);
    $('posts').addEventListener('click', onPostsClick, false);
    $('scrollCloseButton').addEventListener('click', onScrollClose, false);
    var list = $x('.//input', div);
    for (var x=0, limit=list.snapshotLength; x<limit; ++x) {
        var button = list.snapshotItem(x);
        button.addEventListener('mousedown', onScrollButtonMouseDown, false);
    }
    div.addEventListener('mousedown', onScrollBoxMouseDown, false);
    $('dn').addEventListener('click', onDnClick, false);
    $('up').addEventListener('click', onUpClick, false);
}

// Deletes the scroll box created by addScrollBox, if it exists.

function removeScrollBox()
{
    var scrollBox = $('scrollBox');
    if (scrollBox) {
        var posts = scrollBox.parentNode;
        posts.removeChild(scrollBox);
        posts.removeEventListener('click', onPostsClick, false);
    }
}

// Responds to a click in the tree view indentation white space by
// showing the scroll box at the spot clicked.

function onPostsClick(e)
{
    if (e.target.id != 'posts')
        return;
    var scrollBox = $('scrollBox');
    scrollBox.style.display = 'block';
    scrollBox.style.left = (e.clientX - scrollBox.offsetWidth/2) + 'px';
    scrollBox.style.top = e.clientY + 'px';
    colorScrollBox(scrollBox);
}

// Hides the scroll box when the user clicks its Close button.

function onScrollClose(e)
{
    $('scrollBox').style.display = 'none';
}

// Traps mousedowns on scroll box buttons, so they won't start a drag.

function onScrollButtonMouseDown(e)
{
    e.stopPropagation();
}

// Supports dragging the scroll box by its white space areas.

function onScrollBoxMouseDown(e)
{
    this.addEventListener('mousemove', onMouseMove, true);
    this.addEventListener('mouseup', onMouseUp, true);
    var mdx = e.clientX;
    var mdy = e.clientY;
    var mdLeft = parseInt(this.style.left);
    var mdTop = parseInt(this.style.top);
    e.stopPropagation();

    function onMouseMove(e)
    {
        var x = e.clientX - mdx;
        var y = e.clientY - mdy;
        this.style.left = mdLeft + x + 'px';
        this.style.top = mdTop + y + 'px';
        colorScrollBox(this);
        e.stopPropagation();
    }

    function onMouseUp(e)
    {
        this.removeEventListener('mousemove', onMouseMove, true);
        this.removeEventListener('mouseup', onMouseUp, true);
        e.stopPropagation();
    }
}

// Sets the scroll box's background color to correspond to its current
// indent level.

function colorScrollBox(scrollBox)
{
    var depth = scrollBoxDepth(scrollBox);
    scrollBox.style.background = BACKGROUNDS[depth % BACKGROUNDS.length];
}

// Computes the scroll box's indent level, based on its horizontal
// position.

function scrollBoxDepth(scrollBox)
{
    var offset = scrollBox.offsetLeft - $('posts').offsetLeft +
                   Math.floor(scrollBox.offsetWidth/2);
    return offset > 0 ? Math.floor(offset/INDENT) : 0;
}

// Scrolls up to the next post at or left of the scroll box's indent
// level.

function onUpClick(event)
{
    scrollPosts(event, findUp);

    function findUp(boxDepth, boxTop, divs)
    {
        for (var x=divs.snapshotLength-1; x>=0; --x) {
            var div = divs.snapshotItem(x);
            if (div.offsetTop >= boxTop)
                continue;
            var divDepth = parseInt(div.style.marginLeft) / INDENT;
            if (divDepth <= boxDepth)
                break;
        }
        return div;
    }
}

// Scrolls down to the next post at or left of the scroll box's indent
// level.

function onDnClick(event)
{
    scrollPosts(event, findDown);

    function findDown(boxDepth, boxTop, divs)
    {
        for (var x=0, limit=divs.snapshotLength; x<limit; ++x) {
            var div = divs.snapshotItem(x);
            if (div.offsetTop <= boxTop)
                continue;
            var divDepth = parseInt(div.style.marginLeft) / INDENT;
            if (divDepth <= boxDepth)
                break;
        }
        return div;
    }
}

// Scrolls the display so that the post found by the findDiv function
// is opposite the scroll box. Chooses between three different scroll
// methods, depending on the settings in the SCROLL constant. Available
// methods include two types of animation and a simple jump. If the
// control or shift key is down, it always uses the jump method.

function scrollPosts(event, findDiv)
{
    var box = $('scrollBox');
    var boxTop = box.offsetTop + window.pageYOffset;
    var boxDepth = scrollBoxDepth(box);
    var divs = $x('div', $('posts'));
    var div = findDiv(boxDepth, boxTop, divs);
    var scrollDistance = div.offsetTop - boxTop;

    if (/jump/i.test(SCROLL.method) || event.ctrlKey || event.shiftKey)
        jump();
    else if (/time/i.test(SCROLL.method))
        constantTime();
    else if (/speed/i.test(SCROLL.method))
        constantSpeed();
    else
        constantTime();

    // Non-animated, simple scroll.

    function jump()
    {
        window.scrollBy(0, scrollDistance);
    }

    var interval;

    // Animated scroll: Moves the display faster or slower depending
    // on the distance to scroll.

    function constantTime()
    {
        var parms = SCROLL.constantTime;
        var intervalCount = parms.intervalCount;
        interval = window.setInterval(scrollABit, parms.timeInterval);
        document.body.addEventListener('click', abort, true);

        function scrollABit()
        {
            if (intervalCount == 0) {
                abort();
                return;
            }
            var scrollInc = Math.round(scrollDistance / intervalCount--);
            window.scrollBy(0, scrollInc);
            scrollDistance -= scrollInc;
        }
    }

    // Animated scroll: Moves the display at a steady speed until the
    // distance is covered.

    function constantSpeed()
    {
        var parms = SCROLL.constantSpeed;
        var scrollInc = parms.pixelsPerInterval;
        if (scrollDistance < 0)
            scrollInc = -scrollInc;
        interval = window.setInterval(scrollABit,  parms.timeInterval);
        document.body.addEventListener('click', abort, true);

        function scrollABit()
        {
            if (scrollDistance == 0) {
                abort();
                return;
            }
            if (Math.abs(scrollInc) > Math.abs(scrollDistance))
                scrollInc = scrollDistance;
            window.scrollBy(0, scrollInc);
            scrollDistance -= scrollInc;
        }

    }

    // Terminates an animated scroll early if the user clicks.

    function abort()
    {
        window.clearInterval(interval);
        document.body.removeEventListener('click', arguments.callee, true);
    }
}

// -----------------------------------------------------------------------------

// Handles a mouse press on a Quote button. Installs a click handler for
// the button, allowing the click to be handled correctly while preserving
// any text selection the user may have made.

function onQuotePress()
{
    this.removeEventListener('click', onQuoteClick, false);
    this.addEventListener('click', onQuoteClick, false);
    var sel = window.getSelection();
    selection = sel.toString();
    sel.removeAllRanges();  // Deselect the text
}

// Handles a Quote / Unquote button click. If there is already a quoted
// post showing (the Unquote button case), we delete it. Otherwise (the
// Quote button case), we locate the post to be quoted and display it
// in a box above the current post (the one containing the Quote button).
// Unless a post number has been selected with the mouse, we quote the
// the post to which the current post is in reply.

// If the desired post is not in memory, we will load the page that
// contains it in the background, while showing the progress bar.

function onQuoteClick()
{
    var button = this;
    if (selection) {
        var postNumbers = selection.match(/\d+/g);
        if (postNumbers) {
            hideQuote(button);
            quoteSelectedPostNumbers(button, postNumbers);
            return;
        }
    }
    if (button.value == 'Unquote') {
        hideQuote(button);
        return;
    }
    var postNumber = button.previousSibling.hash.substr(1);
    findAndQuotePost(button, postNumber);
}

// Finds and quotes each of a list of posts

function quoteSelectedPostNumbers(button, postNumbers)
{
    for (var x=0; x<postNumbers.length; ++x)
        findAndQuotePost(button, postNumbers[x]);
}

// Finds and quotes a given post. Runs right away if the desired post
// is in memory. Otherwise, it loads the page containing the post in
// the background before continuing.

function findAndQuotePost(button, postNumber)
{
    if (findPost(postNumber))
        quotePost(button, postNumber);
    else {
        disable(button, 'Waiting ...');
        var l = window.location;
        var url = l.protocol + '//' + l.hostname + l.pathname +
                    '?page=' + postNumber + '#' + postNumber;
        loadLink(url, function() {
            quotePost(button, postNumber);
        });
        button.disabled = false;
    }
}

// Looks for the post in the posts index. If there is no posts index,
// we build one for the current page. Returns undefined if not found.

function findPost(postNumber)
{
    hideProgress();
    if (!posts)
        indexPosts(originalHTML);
    return posts[postNumber];
}

// Copies the post to be quoted into a box above the post containing
// the Quote button.

function quotePost(button, postNumber)
{
    var post = findPost(postNumber);
    post = post || '<b>Unable to retrieve post #' + postNumber + '.</b>';
    var div = button.ownerDocument.createElement('div');
    div.className = 'quoteBox';
    div.innerHTML = post;
    addQuoteButtons(div);
    removeBlankLines(div);
    fixToLink(div);
    localizeDates(div);
    var anchorDiv = findAnchorDiv(button);
    if (anchorDiv.className != 'postBox')
        anchorDiv.parentNode.insertBefore(div, anchorDiv);
    else
        anchorDiv.insertBefore(div, anchorDiv.firstChild);
    button.value = 'Unquote';
    window.scrollBy(0, div.offsetHeight);
}

// Ensures that a quoted post's To link will work in its possibly
// new context.

function fixToLink(div)
{
    var link = $xFirst('div[@class="n2"]/a[contains(., "To ")]', div);
    if (link) {
        var postNumber = link.hash.substr(1);
        if (document.anchors.namedItem(postNumber))
            return;
        link.href = 'posts?page=' + postNumber + '#' + postNumber;
    }
}

// Figures out where to put the quote box.

function findAnchorDiv(node)
{
    var p = node.parentNode;
    while (p.parentNode.tagName != 'BODY' && p.parentNode.id != 'posts')
        p = p.parentNode;
    do {
        p = p.previousSibling;
    } while (p && p.tagName != 'A');
    do {
        p = p.nextSibling;
    } while (p && p.tagName != 'DIV');
    return p;
}

// Hides a quoted post and scrolls the window to avoid disorienting
// the user.

function hideQuote(button)
{
    if (button.value != 'Unquote')
        return;
    var maxScroll = window.scrollMaxY - window.scrollY;
    var totalHeight = 0;
    button.value = 'Quote';
    var quotedPost = button;
    var gp = quotedPost.parentNode.parentNode;
    if (gp.tagName != 'DIV' || gp.className != 'quoteBox')
        quotedPost = quotedPost.parentNode;
    else
        quotedPost = gp;
    do {
        quotedPost = quotedPost.previousSibling;
    } while (quotedPost && quotedPost.className != 'quoteBox');
    do {
        totalHeight += quotedPost.offsetHeight;
        var sibling = quotedPost.previousSibling;
        quotedPost.parentNode.removeChild(quotedPost);
        quotedPost = sibling;
    } while (quotedPost && quotedPost.tagName == 'DIV');
    window.scrollBy(0, -(totalHeight<maxScroll ? totalHeight : maxScroll));
}

// Locates the quote button in a quote box.

function findQuoteButton(quoteDiv)
{
    return $xFirst('div[@class="n2"]/input[@class="quoteButton"]', quoteDiv);
}

// -----------------------------------------------------------------------------

// Constructs an object to keep track of the data about a poster in
// the thread (for the Poster Report).

function Poster(key, name, age, serial)
{
    this.key = key;                // To construct home page link
    this.name = name;              // Display name
    this.sortKey = name.toLowerCase();
    this.age = age >= 0 ? age : 0; // How long on FR?
    this.serial = serial;          // Order of first appearance on thread
    this.postCount = 0;
    this.replyCount = 0;
}

Poster.makeHeader = function(s)
{
    s += '<tr>';
    s += '<th class=numh>Rank</th>';
    s += '<th>Poster</th>';
    s += '<th class=numh>FR<br>Age</th>';
    s += '<th class=numh>Posts</th>';
    s += '<th class=numh>Replies</th>';
    s += '<th class=numh>Replies<br>per Post</th>';
    s += '</tr>\n';
    return s;
}

Poster.prototype.calcReplyRatio = function()
{
    this.replyRatio = this.replyCount / this.postCount;
    return this.replyRatio;
}

// Generates the HTML for a row of the Poster Report.

Poster.prototype.makeRow = function(s, n)
{
    s += '<tr><td class=num>' + n + '</td>';
    s += '<td><a href="http://www.freerepublic.com/~' + this.key + '/"' +
         ' target="_blank">';
    if (this.isOriginalPoster)
        s += '<b>';
    s += this.name;
    if (this.isOriginalPoster)
        s += '</b>';
    s += '</a></td>'
    s += '<td class=num>' + formatAge(this.age) + '</td>';
    s += '<td class=num>' + this.postCount + '</td>';
    s += '<td class=num>' + this.replyCount + '</td>';
    s += '<td class=num>' + (this.calcReplyRatio()).toFixed(1) + '</td>';
    s += '</tr>\n'
    return s;
}

function formatAge(age)
{
    age /= 86400000;
    if (age > 360)
        return (age/365.25).toFixed(1) + 'y';
    if (age > 60)
        return (age/30).toFixed(1) + 'm';
    if (age > 21)
        return (age/7).toFixed(1) + 'w';
    return age.toFixed() + 'd';
}

var posters;            // Table of Poster objects, accessed by name
var posterList;         // Sortable array (contents of posters)
var posterWindow;       // Window into which to write poster report
var removedPostCount;   // Count of admin-removed posts

// Handles a click on the Poster Report button (or Ctrl-Alt-R).

function onPosterReportClick(event, button)
{
    button = button || this;
    disable(button, 'Waiting ...');
    disable('treeButton');
    openPosterWindow();
    window.setTimeout(function() {indexThread(button, makePosterReport)}, 0);
}

// Opens the poster report window. Must run in response to the user
// click, not the multipage load completion, or else the popup will
// be blocked (unless the user has added FR to the exception list).

function openPosterWindow()
{
    if (!posterWindow || posterWindow.closed)
        posterWindow = window.open('about:blank', 'posterReport');
    if (posterWindow) {
        posterWindow.blur();
        window.focus();
    }
}

// Builds the table of posters. Runs thru the posts index and the
// replies and accumulates stats about each poster in his or her
// Poster object.

function makePosterReport(ok)
{
    if (!ok) {
        enableAfterPosterReport();
        return;
    }
    var startDate = new Date();
    var now = new Date().getTime();
    posters = {};
    removedPostCount = 0;
    var regexp = new RegExp('<a href="/(?:%7E|~)([^/]*)/" title="' +
                        'Since (\\d\\d\\d\\d-\\d\\d-\\d\\d)">([^<]*)</a>', 'i');
    var list = [];
    for (var postNumber in posts)
        list.push(postNumber);
    list.sort(function(a, b) {return a-b;});
    for (var x=0, limit=list.length; x<limit; ++x) {
        var postNumber = list[x];
        var r = regexp.exec(posts[postNumber]);
        if (!r) {
            ++removedPostCount;
            continue;
        }
        var name = r[3];
        var poster = posters[name];
        if (!poster) {
            var date = Date.parse(r[2].replace(/-/g, '/') + ' GMT');
            poster = new Poster(r[1], name, now - date, x);
            posters[name] = poster;
            poster.isOriginalPoster = postNumber == 1;
        }
        ++poster.postCount;
        var replyList = replies[postNumber];
        if (replyList)
            poster.replyCount += replyList.length;
    }
    sortPostersBy('serial', false);
    try {
        showPosters();
    }
    catch(e) {
        alert("Error: " + e);
    }
    logTime(startDate, 'show the poster report on ' +
                 posterList.length + ' posters');
    enableAfterPosterReport();
}

function enableAfterPosterReport()
{
    enable('posterReport', 'Poster Report');
    enable('treeButton');
}

makePosterReport.title = 'poster report';

// Displays the Poster Report in a popup window.

function showPosters()
{
    if (!posterWindow)
        return;
    var s = '<html><head><title>Poster Report</title>\n' +
            '<style>' +
            'table {border-collapse:collapse;' +
                   'border:1px solid #00f;}' +
            'td, th {border:1px inset #ccf;padding-left:3px;padding-right:3px}' +
            'th {cursor:pointer;background:#ffc;vertical-align:bottom}' +
            'h2 {color:darkred}' +

            '.numh {text-align:right}' +
            '.num {text-align:right;font:bold smaller monospace}\n' +
            '</style>\n' +
            '</head><body>\n' +
            '<h2>' + document.title + '</h2>\n' +
            '<h3>Poster Report</h3>\n';
    s += '<table>\n';
    s = Poster.makeHeader(s);
    var n = 0;
    var postCount = 0;
    for (var x=0, limit=posterList.length; x<limit; ++x) {
        var poster = posterList[x];
        s = poster.makeRow(s, ++n);
        postCount += poster.postCount;
    }
    s += '</table>\n';
    s += '<br>' + postCount + ' total posts, by ' + posterList.length +
            ' distinct posters. ' + (postCount/posterList.length).toFixed(1) +
            ' average posts per poster.\n';
    if (removedPostCount) {
        s += '<br>' + removedPostCount + ' post' +
             (removedPostCount == 1 ? ' was' : 's were') + ' removed.';
    }
    s += '<br>Average poster seniority: ' + formatAge(averageAge()) + '.';
    var div = posterWindow.document.createElement('div');
    div.innerHTML = s;
    var b = posterWindow.document.body;
    var oldDiv = b.firstChild;
    b.appendChild(div);
    if (oldDiv)
        b.removeChild(oldDiv);
    var xp = $x('.//th', posterWindow.document.body);
    for (var x=0, limit=xp.snapshotLength; x<limit; ++x)
        xp.snapshotItem(x).addEventListener('click', onHeaderClick, false);
    posterWindow.focus();
}

function averageAge()
{
    var total = 0;
    for (var x=0, limit=posterList.length; x<limit; ++x)
        total += posterList[x].age;
    return total / limit;
}

// Receives control when the user clicks on a table header in the Poster
// Report. Sorts the table by the selected column (or simply reverses it
// if it's already sorted by that column). Then redisplays the report.

function onHeaderClick()
{
    var text = this.innerHTML;
    var parm = ['serial', false];
    if (/Poster/.test(text))
        parm = ['sortKey', false];
    else if (/Age/.test(text))
        parm = ['age', true];
    else if (/Posts/.test(text))
        parm = ['postCount', true];
    else if (/Replies$/.test(text))
        parm = ['replyCount', true];
    else if (/per Post/.test(text))
        parm = ['replyRatio', true];
    if (parm[0] == posterList.property)
        posterList.reverse();
    else
        sortPostersBy(parm[0], parm[1]);
    showPosters();
}

// Sorts the posters by the indicated Poster object property, in
// ascending or descending order.

function sortPostersBy(property, backwards)
{
    posterList = [];
    posterList.property = property;
    for (name in posters)
        posterList.push(posters[name]);
    posterList.sort(comparator);

    function comparator(a, b) {
        var r;
        if (a[property] > b[property])
            r = 1;
        else if (a[property] < b[property])
            r = -1;
        else if (a.sortkey > b.sortKey)
            return 1;
        else if (a.sortKey < b.sortKey)
            return -1;
        else
            return 0;
        if (backwards)
            r = -r;
        return r;
    }
}

// -----------------------------------------------------------------------------

function $(id) {return document.getElementById(id);}

function $x(xpath, contextNode, resultType)
{
    contextNode = contextNode || document.body;
    resultType = resultType || XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE;
    var doc = contextNode.ownerDocument; // FF3; can't just use document
    return doc.evaluate(xpath, contextNode, null, resultType, null);
}

function $xFirst(xpath, contextNode)
{
    var xpr = $x(xpath, contextNode, XPathResult.FIRST_ORDERED_NODE_TYPE);
    return xpr.singleNodeValue;
}

// Disables a button and sets its text to the supplied string.

function disable(button, msg)
{
    if (typeof button == 'string')
        button = $(button);
    if (msg)
        button.value = msg;
    button.disabled = true;
}

// Enables a button and sets its text to the supplied string.

function enable(button, msg)
{
    if (typeof button == 'string')
        button = $(button);
    if (msg)
        button.value = msg;
    button.disabled = false;
}

// Adds the style rules defined in the STYLE constant to the page.

function addStyles()
{
    var style = document.createElement('style');
    style.innerHTML = STYLE;
    document.getElementsByTagName('head')[0].appendChild(style);
}

// Makes the link to the article to be discussed go directly to the
// article without pausing and redirecting.

function fixArticleRedirect()
{
    var link = $xFirst('.//a[starts-with(@href, "/^")]');
    if (link)
        link.href = link.href.replace(/^.*?\%5E/, '');
}

// Removes those extra blank lines that seem to crop up at the end
// of certain posts.

function removeBlankLines(doc)
{
    var list = $x('.//br[@clear="all"]', doc);
    for (var x=0, limit=list.snapshotLength; x<limit; ++x) {
        var br = list.snapshotItem(x);
        br.parentNode.removeChild(br);
    }
}

// Converts posting date stamps from Pacific to local time.

function localizeDates(doc)
{
    var list = $x('.//span[@class="date"]', doc);
    for (var x=0, limit=list.snapshotLength; x<limit; ++x) {
        var date = list.snapshotItem(x);
        date.innerHTML = new Date(date.innerHTML).toLocaleString();
    }
}

// Adds a Quote button next to the To link in each post having one.

function addQuoteButtons(doc)
{
    var quoteButtonModel = document.createElement('input');
    quoteButtonModel.className = 'quoteButton';
    quoteButtonModel.type = 'button';
    quoteButtonModel.value = 'Quote';

    var xp = $x('.//div[@class="n2"]/a[contains(., "To ")]', doc);
    for (var x=0, limit=xp.snapshotLength; x<limit; ++x) {
        var link = xp.snapshotItem(x);
        if (/^To \d+$/.test(link.innerHTML)) {
            var quoteButton = quoteButtonModel.cloneNode(true);
            quoteButton.addEventListener('mousedown', onQuotePress, false);
            link.parentNode.insertBefore(quoteButton, link.nextSibling);
        }
    }
}

// Adds the Tree View and Poster Report buttons at the top of the page,
// next to the 'comments' link.

function addButtons()
{
    var node = $xFirst('//a[contains(@href, "#comment")]');
    node.innerHTML += '&nbsp;<input type=button value="View as Tree" ' +
                      'id=treeButton title="Ctrl-Alt-T">';
    var span = document.createElement('span');
    span.innerHTML = ' <input type=button value="Poster Report" ' +
                     'id=posterReport title="Ctrl-Alt-R">';
    node.parentNode.appendChild(span);

    $('treeButton').addEventListener('click', onTreeViewClick, false);
    $('posterReport').addEventListener('click', onPosterReportClick, false);
}

// Installs a keystroke event handler to catch the keyboard shortcuts
// for the Tree View and the Poster Report.

function addKeys()
{
    window.addEventListener('keypress', onKeyPress, false);
}

// Adds a blue box at the end of the page, in which to write debugging
// messages.

function addLogBox()
{
    var div = document.createElement('div');
    div.id = 'logBox';
    document.body.appendChild(div);
}

// Writes a log message into the log box at the bottom of the page.

function log(msg)
{
    var logBox = $('logBox');
    var html = logBox.innerHTML;
    if (html)
        html += '<br>\n';
    logBox.innerHTML = html + msg;
}

// Logs the time it took to perform a given task.

function logTime(startTime, task)
{
    var time = new Date() - startTime;
    var s = 'It took ' + time + ' ms to ' + task;
    if (posts) {
        var n = posts.length - 1;
        s += ' for ' + n + ' posts; ' + (time/n).toFixed(2) +
                    ' ms/post';
    }
    log(s + '.');
}

// Scrolls the window to the internal anchor indicated by the 'hash'.

function fixLocation()
{
    location.hash = location.hash;
}

// ------ Decrudify ------------------------------------------------------------

// Decrudify fixes garbage characters introduced into posts by a recent
// FR server bug in which the server breaks up the individual bytes
// of posted UTF-8 sequences into their own separate HTML entities,
// resulting in garbage characters on the screen.

// E.g., a left curly double-quote is unicode \u201c, which takes three
// bytes to represent in UTF-8: e2 80 9c. Instead of passing the UTF-8
// unscathed, the server substitutes the entities for small-a with a
// circumflex (e2), the euro-symbol (80), and the oe ligature (9c),
// resulting in gibberish on the screen. Decrudify finds such garbage
// sequences and substitutes the originally intended character.


// A hash containing an entry for each entity value that converts to a
// character in the range \u0080 thru \u00ff.
var charCodeToByte = {
    8364: 128,
    129: 129,
    8218: 130,
    402: 131,
    8222: 132,
    8230: 133,
    8224: 134,
    8225: 135,
    710: 136,
    8240: 137,
    352: 138,
    8249: 139,
    338: 140,
    141: 141,
    381: 142,
    143: 143,
    144: 144,
    8216: 145,
    8217: 146,
    8220: 147,
    8221: 148,
    8226: 149,
    8211: 150,
    8212: 151,
    732: 152,
    8482: 153,
    353: 154,
    8250: 155,
    339: 156,
    157: 157,
    382: 158,
    376: 159,
    160: 160,
    161: 161,
    162: 162,
    163: 163,
    164: 164,
    165: 165,
    166: 166,
    167: 167,
    168: 168,
    169: 169,
    170: 170,
    171: 171,
    172: 172,
    173: 173,
    174: 174,
    175: 175,
    176: 176,
    177: 177,
    178: 178,
    179: 179,
    180: 180,
    181: 181,
    182: 182,
    183: 183,
    184: 184,
    185: 185,
    186: 186,
    187: 187,
    188: 188,
    189: 189,
    190: 190,
    191: 191,
    192: 192,
    193: 193,
    194: 194,
    195: 195,
    196: 196,
    197: 197,
    198: 198,
    199: 199,
    200: 200,
    201: 201,
    202: 202,
    203: 203,
    204: 204,
    205: 205,
    206: 206,
    207: 207,
    208: 208,
    209: 209,
    210: 210,
    211: 211,
    212: 212,
    213: 213,
    214: 214,
    215: 215,
    216: 216,
    217: 217,
    218: 218,
    219: 219,
    220: 220,
    221: 221,
    222: 222,
    223: 223,
    224: 224,
    225: 225,
    226: 226,
    227: 227,
    228: 228,
    229: 229,
    230: 230,
    231: 231,
    232: 232,
    233: 233,
    234: 234,
    235: 235,
    236: 236,
    237: 237,
    238: 238,
    239: 239,
    240: 240,
    241: 241,
    242: 242,
    243: 243,
    244: 244,
    245: 245,
    246: 246,
    247: 247,
    248: 248,
    249: 249,
    250: 250,
    251: 251,
    252: 252,
    253: 253,
    254: 254,
    255: 255,
};

// Makes a regular expression that matches UTF-8 sequences
function makeUtf8Regex(table) {
    table = table || charCodeToByte;
    var keys = Object.getOwnPropertyNames(table).sort();
    var begRange = keys[0]*1;
    var prevKey = begRange;
    var regExp = '[' + toUnicodeLit(192) + '-' + toUnicodeLit(247) + '][';
    for (var x=1; x<keys.length; x++) {
        var c = keys[x]*1;
        if (c >= 192 && c <= 247)
            continue;
        if (c == prevKey + 1) {
            prevKey++;
            continue;
        }
        addToRegExp();
        begRange = c;
        prevKey = begRange;
    }
    addToRegExp();
    regExp += ']+';
    return new RegExp(regExp, 'g');

    function addToRegExp() {
        regExp += toUnicodeLit(begRange);
        if (begRange < prevKey)
            regExp += '-' + toUnicodeLit(prevKey);
    }
}

// Converts a numeric character code to a unicode hex literal (\uxxxx)
function toUnicodeLit(charCode) {
    var hex = ('000' + charCode.toString(16)).slice(-4);
    return '\\u' + hex;
}

var regExp = makeUtf8Regex(charCodeToByte);

// Returns the input string with any UTF-8 sequences converted to JavaScript
// code points
function utf8ToString(utf8) {
    fixCount++;
    var p = 0;
    var result = '';
    while (p < utf8.length) {
        var c = utf8.charCodeAt(p++);
        switch (c >> 4) {
            case 0:
            case 1:
            case 2:
            case 3:
            case 4:
            case 5:
            case 6:
            case 7:
                result += String.fromCharCode(c);
                break;
            case 8:
            case 9:
            case 10:
            case 11:
                console.log("Bad UTF-8 string: " + utf8.slice(p-16, p+16) +
                                ' p = ' + p);
                break;
            case 12:
            case 13:
                result += String.fromCharCode((c & 31) << 6 |
                                                charCodeAt(utf8, p++));
                break;
            case 14:
                result += String.fromCharCode((c & 15) << 12 |
                                                charCodeAt(utf8, p++) << 6 |
                                                charCodeAt(utf8, p++));
                break;
            case 15:
                result += String.fromCodePoint((c & 7) << 18 |
                                                charCodeAt(utf8, p++) << 12 |
                                                charCodeAt(utf8, p++) << 6 |
                                                charCodeAt(utf8, p++));
                break;
            default:
                console.log("Bad charCode " + c + ' at p = ' + p + ' in ' +
                                    utf8);
        }
    }
    return result;
}

// Returns the byte value for the charCode at position x in the UTF-8 string.
// E.g., charCode 8364 (the euro-sign) converts to 128
function charCodeAt(utf8, x) {
    var c = charCodeToByte[utf8.charCodeAt(x)];
    if ((c >> 6) != 2)
        console.log("Bad UTF8 char " + c + " at " + x + " in " + utf8);
    return c & 63;
}

var fixCount;

// Walks all the text nodes in the document and decrudifies each one
function decrudify() {
    var t = performance.now();
    fixCount = 0;
    var nodeWalker = document.createTreeWalker(document.body,
                                               NodeFilter.SHOW_TEXT);
    while (nodeWalker.nextNode())
        decrudifyTextNode(nodeWalker.currentNode);
    t = performance.now() - t;
    var msg = "Decrudify made " + fixCount + " fix" +
                                (fixCount == 1 ? '' : 'es') +
                                        ' in ' + t.toFixed(1) + "ms.";
    log(msg);
    console.log(msg);
}

// Runs utf8ToString on any UTF-8 sequences in a text node until no change
// results. Replaces the text node if any change occurred
function decrudifyTextNode(node) {
    var text = node.textContent;
    var originalText = text;
    while (true) {
        var newText = text.replace(regExp, utf8ToString);
        if (newText == text)
            break;
        text = newText;
    }
    if (newText != originalText)
        node.textContent = newText;
}

// -----------------------------------------------------------------------------

// Main program ...

var startTime = new Date();

addStyles();
addLogBox();
fixArticleRedirect();
decrudify();
var originalHTML = document.body.innerHTML;
removeBlankLines(document.body);
addQuoteButtons(document.body);
addButtons();
addKeys();

logTime(startTime, 'prepare the page');

// Try to correct positioning error when going to internal anchors.
if (location.hash) {
    fixLocation();
    document.body.addEventListener('load', fixLocation, false);
    window.setTimeout(fixLocation, 500);
}