FR Tree Viewer

Brings together comments and their replies.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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);
}