MetaFilter Filter By Favorites

Allows users to view MetaFilter comments by favorite count.

As of 2014-10-14. See the latest version.

// ==UserScript==
// @name           MetaFilter Filter By Favorites
// @namespace      http://namespace.kinobe.com/metafilter/
// @description    Allows users to view MetaFilter comments by favorite count.
// @include        /^https?://(www|ask|metatalk|fanfare|projects|music|irl)\.metafilter\.com/.*$/
// @include        http://mefi/*
// @version        1.0
// @grant           GM_addStyle
// ==/UserScript==

/*

 This copyright section and all credits in the script must be included in modifications or redistributions of this script.

 MetaFilterFilterByFavorites is Copyright (c) 2014, Jonathan Gordon
 MetaFilterFilterByFavorites is licensed under a Creative Commons Attribution-Share Alike 3.0 Unported License
 License information is available here: http://creativecommons.org/licenses/by-sa/3.0/

*/

/*
 This script borrows heavily from Jimmy Woods' MetaFilter favorite posts filter script
 http://userscripts.org/scripts/show/75332

 Also from Jordan Reiter's MetaFilter MultiFavorited Multiwidth - November Experiment
 http://userscripts.org/scripts/show/61012

 Please see the README.md for more info:

 https://greasyfork.org/scripts/5717-metafilter-filter-by-favorites
 
 Version 1.0
 - Initial Release.
 */

var LogLevelEnum = {
    DEBUG:{value:0, name:"Debug"},
    INFO:{value:1, name:"Info"},
    WARN:{value:2, name:"Warn"},
    ERROR:{value:3, name:"Error"}
};

var SiteEnum = {
    WWW:{
        name:"www", titleRE:/^.+?\| MetaFilter$/, fav_prefix:"2"
    }, ASK:{
        name:"ask", titleRE:/^.+?\| Ask MetaFilter$/, fav_prefix:"4"
    }, TALK:{
        name:"talk", titleRE:/^.+?\| MetaTalk$/, fav_prefix:"6"
    }, PROJECTS:{
        name:"projects", titleRE:/^.+?\| MetaFilter Projects$/, fav_prefix:"13"
    }, MUSIC:{
        name:"music", titleRE:/^.+?\| MeFi Music$/, fav_prefix:"9"
    }, IRL:{
        name:"irl", titleRE:/^.+?\| IRL: MeFi Events$/, fav_prefix:"20"
    }, FANFARE:{
        name:"fanfare", titleRE:/^.+?\| FanFare$/, fav_prefix:"24"
    }
};

Global = {
    last_tr:null        // Reference to the last TR tag in the select table that a user clicked on.
    , table_bg_color:"#E6E6E6"   // Background color for the table rows.
    , selected_color:"#88c2d8"     // BG color for the selected table row.
    , hover_color:"#DC5E04"     // BG color for the selected table row.
    , favorite_color:"#ff7617"     // BG color for the selected table row.
    , max_count:100     // Largest possible # of favorites
    , min_count:0     // Smallest # of favorites that are highlighted
    , posts:[]        // Stores info about each post
    , max_favorites:0   // Highest favorite count so far.
    , doLog:true   // Should we log messages?
    , row_prefix:"summary_id_" // Used to set the ID for each row in the comment/favorite chart
    , logLevel:LogLevelEnum.INFO   // What's the default log level?
};


/**
 * ----------------------------------
 * Logger
 * ----------------------------------
 * Allows swapping out GM logger for console
 */
Logger = {

    log:function (message, logLevelEnum) {
        logLevelEnum = logLevelEnum || LogLevelEnum.INFO;

        if (Global.doLog && logLevelEnum.value >= Global.logLevel.value) {
//            GM_log(message);
            console.log(message);
        }
    }, debug:function (message) {
        Logger.log(message, LogLevelEnum.DEBUG);
    }, info:function (message) {
        Logger.log(message, LogLevelEnum.INFO);
    }, warn:function (message) {
        Logger.log(message, LogLevelEnum.WARN);
    }, error:function (message) {
        Logger.log(message, LogLevelEnum.ERROR);
    }
};

/**
 * ----------------------------------
 * Util
 * ----------------------------------
 * Various utility functions
 */
Util = {
    /**
     * Returns an array of DOM elements that match a given XPath expression.
     *
     * @param path string - Xpath expression to search for
     * @param from DOM Element - DOM element to search under. If not specified, document is used
     * @return Array - Array of selected nodes (if any)
     */
    getNodes:function (path, from) {

        Logger.debug("getNodes of path: " + path);

        from = from || document;

        var item, ret = [];
        var iterator = document.evaluate(path, from, null, XPathResult.ANY_TYPE, null);
        while (item = iterator.iterateNext()) {
            ret.push(item);
//            Logger.debug("Item is: "+item);

        }
        Logger.debug("Num elements found by getNodes: " + ret.length);
        return ret;
    }

    /**
     * Deletes a DOM element
     * @param DOM element - DOM element to remove
     * @return DOM element - the removed element
     */, removeElement:function (element) {
        return element.parentNode.removeChild(element);
    }

    /**
     * Binds an event handler function to an object context, so that the handler can be executed as if it
     * was called using "this.<method name>(event)", i.e. it can use "this.foo" inside it.
     *
     * @param function method - a function to execute as an event handler
     * @param Object context - the object that will be used as context for the function, as if the function had been
     *          called as context.method(event);
     * @return function - the function to pass to addEventListener
     */, bindAsEventHandler:function (method, context) {
        var __method = method;
        return function (event) {
            return __method.apply(context, [event]);
        }
    }
};

/*
 * Event handler for when user clicks on a row
 */
function filterPosts(evt) {
    // Find the parent <TR> tag.

    Logger.debug("filterPosts");
    var t = evt.target;
    Logger.debug("t: " + t);
    while (null == t.getAttribute("id")) {
        Logger.debug("Looking for DIV");
        t = t.parentNode;
    }

    var summary_id = t.getAttribute('id');
    Logger.debug("t.id: " + summary_id);
    var summary_row_re = /^summary_id_(\d+)$/;
    var max_cnt = (summary_row_re.exec(summary_id) !== null) ? parseInt(RegExp.$1) : 0;

    Logger.debug("Parsed max_cnt: " + max_cnt);


    // Hide/unhide all posts that don't match the chosen fav count.
    var i = Global.posts.length;
    while (i--) {
        var is_showing = (Global.posts[i].div.style.display !== "none");
        var do_show = (Global.posts[i].num_favs >= max_cnt);

        Logger.debug("is_showing: " + is_showing);
        Logger.debug("do_show: " + do_show);

        if (do_show != is_showing) {
            Logger.debug("Hiding post: " + i);

            Global.posts[i].div.style.display = (do_show ? "" : "none");
            Global.posts[i].div.nextSibling.style.display = (do_show ? "" : "none");
            Global.posts[i].div.nextSibling.nextSibling.style.display = (do_show ? "" : "none");
        }
    }

    // Reset the color of the previous row to be clicked on.
    if (Global.last_tr !== null) {
        Logger.debug("Resetting the background color.");

        removeClass(Global.last_tr, "wrapperSelected");

    }
    // Set the color of the row we just clicked on
    addClass(t, "wrapperSelected");
    Global.last_tr = t;
}

function addClass(obj, className) {
    if (null != obj && undefined != obj) {
        var prevClass = obj.className;

        if (null != prevClass && undefined != prevClass) {
            if (!prevClass.match(new RegExp(className))) {
                obj.className = obj.className + " " + className;
            }
        }
    }
}

function removeClass(obj, className) {
    if (null != obj && undefined != obj) {
        var prevClass = obj.className;

        if (null != prevClass && undefined != prevClass) {
            var regExp = new RegExp(className);
            if (prevClass.match(regExp)) {
                obj.className = obj.className.replace(regExp, '');
            }
        }
    }

}

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

//Finds y value of given object
function findPos(obj) {
    var current_top = 0;
    if (obj.offsetParent) {
        do {
            current_top += obj.offsetTop;
        } while (obj = obj.offsetParent);
    }
    return current_top;
}
function simulateClickShow(id) {

// jquery isn't working here
//            $('#filter0').trigger('click');

// use non-jquery method to simulate the click of the count row specified
    var evt = document.createEvent("MouseEvents");
    evt.initMouseEvent("click", true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
    var show_all_link = document.getElementById(id);
    show_all_link.dispatchEvent(evt);

}

function getElementsByClassName(node, classname) {
    if (node.getElementsByClassName) { // use native implementation if available
        Logger.debug("Using native implementation of getElementsByClassName.");
        return node.getElementsByClassName(classname);
    } else {
        return (function getElementsByClass(searchClass, node) {
            node = node || document;
            var classElements = [], els = document.getElementsByTagName("*"), elsLen = els.length, pattern = new RegExp("(^|\\s)" + searchClass + "(\\s|$)"), i, j;
            Logger.debug("Total elements: " + els.length);
            Logger.debug("Looking for" + searchClass);

            for (i = 0, j = 0; i < elsLen; i++) {

                var elsClassName = els[i].className;
                if ("" != elsClassName) {
//                    Logger.debug("Class of element: " + elsClassName);
                }
                if (pattern.test(elsClassName)) {
                    classElements[j] = els[i];
                    j++;
                }
            }
            return classElements;
        })(classname, node);
    }
}


// a function that loads jQuery and calls a callback function when jQuery has finished loading
function addJQuery(callback) {
    var script = document.createElement("script");
    script.setAttribute("src", "http://ajax.googleapis.com/ajax/libs/jquery/1.6.4/jquery.min.js");
    script.addEventListener('load', function () {
        var script = document.createElement("script");
        script.textContent = "(" + callback.toString() + ")();";
        document.body.appendChild(script);
    }, false);
    document.body.appendChild(script);
}

function captureShowClick(e) {

    var click_target = e.target;
    while (click_target.tagName != "SPAN") {
        click_target = click_target.parentNode;
    }

    Logger.debug("e.target is: " + click_target);
    Logger.debug("e.target.id is: " + click_target.id);

    var recommended_re = /^(\d+)_(\d+)$/;

    var id = recommended_re.exec(click_target.id)[1];
    Logger.debug("ID is: " + id);
    var count = recommended_re.exec(click_target.id)[2];
    Logger.debug("Count is: " + count);

    var comment_anchor = Util.getNodes('.//a[@name="' + id + '"]')[0];
    var prevPos = findPos(comment_anchor);
    Logger.debug("prevPos: " + prevPos);
    Logger.debug("Previous window.pageYOffset: " + window.pageYOffset);

    var diff = prevPos - window.pageYOffset;

    simulateClickShow(Global.row_prefix + count);

    //Get object
    Logger.debug("Did we find SupportDiv? " + comment_anchor);

//Scroll to location of SupportDiv on load
    var newPos = findPos(comment_anchor);
    Logger.debug("newPos: " + newPos);
    Logger.debug("Current window.pageYOffset (before scrolling): " + window.pageYOffset);

    window.scroll(0, newPos - diff);
    Logger.debug("Current window.pageYOffset (after scrolling): " + window.pageYOffset);

//    simulateClickShow(id);
    return false;
}

function getSite() {

    // Which subsite are we on?
    var title = document.title;
    Logger.debug("document.title: >" + title + "<");

    for (var propertyName in SiteEnum) {
        // propertyName is what you want
        if (SiteEnum[propertyName].titleRE.test(title)) {
            return SiteEnum[propertyName];
        }
    }
    return null;

}

//check if the previous sibling node is an element node
function getPreviousElement(n) {
    var x = n.previousSibling;
    while (null != x && x.nodeType != 1) {
        x = x.previousSibling;
        Logger.debug("Previous sibling?: " + typeof x);
        Logger.debug("Previous sibling: " + x);
    }
    return x;
}
function init() {
    Logger.info("Loading MetaFilterFilterByFavorites...");

    // if we can't find comments, it's probably this is being called for a page we haven't excluded
    if (undefined == document.getElementById("posts")) {
        Logger.info("MetaFilterFilterByFavorites can not find top node. Exiting.");
        return;
    }

    Logger.debug("MetaFilterFilterByFavorites found top node. Continuing...");

    var site = getSite();

    if (null == site) {
        Logger.error("MetaFilterFilterByFavorites can not determine site. Exiting...");
        return;
    }

    Logger.debug("site: " + site.name);

    // Prepare array for storing counts of how many posts have been favorited this many times.
    var counts = [];
    for (var j = 0; j <= Global.max_count; j++) {
        counts[j] = 0;
    }

    // some useful regexes for parsing ids and such
    var numeric_re = /^(\d+)$/, favorites_re = /^(\d+)\sfavorite[s]?$/;

    // Get all comments and compile them into arrays
    var commentDivs = Util.getNodes('.//div[@id="posts"]//div[contains(concat(" ", normalize-space(@class), " "), " comments ")]');

    Logger.debug("Num comments found: " + commentDivs.length);

    // if there are no comments, don't show table
    if (0 == commentDivs.length) {
        Logger.info("MetaFilterFilterByFavorites can not find comments. Exiting.");
        return;
    }

    for (var i = 0; i < commentDivs.length; i++) {
        Logger.debug("MetaFilterFilterByFavorites found comment div. Continuing...");

        var comment_div = commentDivs[i];
        Logger.debug("Found comment_div: " + comment_div.textContent);

        var sibling_a = getPreviousElement(comment_div);

        // if the comment doesn't have a previous sibling, we're not interested
        if (null == sibling_a) {
            continue;
        }

        Logger.debug("sibling_a: " + typeof sibling_a);
        Logger.debug("sibling_a.name: " + sibling_a.name);

        var comment_div_id = sibling_a.name;
//        Logger.debug("Id is: " + comment_div_id);
        Logger.debug("comment_div_id: " + comment_div_id);

        if (comment_div_id !== undefined && numeric_re.test(comment_div_id)) {
            Logger.debug("Found a valid id: " + comment_div_id);


            var fav_count_a = Util.getNodes('.//span[@id="favcnt' + site.fav_prefix + comment_div_id + '"]/a')[0];
            Logger.debug("fav_count_a: " + fav_count_a);
            Logger.debug("typeof fav_count_a: " + typeof fav_count_a);

            var recommended_text = undefined !== fav_count_a ? fav_count_a.textContent : "0 favorites";

            var favorite_count = (favorites_re.exec(recommended_text) !== null) ? Math.min(parseInt(RegExp.$1), Global.max_count) : 0;
            Logger.debug("favorite_count: " + favorite_count);
            counts[favorite_count]++;
            Logger.debug("Done pushing recommended_count: " + favorite_count);

            // we only highlight if there's a fav count over the minimum
            if (favorite_count > Global.min_count) {
                Logger.debug("recommended_count > " + Global.min_count + ":  " + favorite_count);

                var recommendedWidthSize = (Math.round(favorite_count / 2) + 1);
                comment_div.style.borderLeft = '' + recommendedWidthSize + 'px solid ' + Global.favorite_color;
                comment_div.style.borderTop = '0px';
                comment_div.style.borderBottom = '0px';
                comment_div.style.paddingLeft = '5px';
            }


            Global.max_favorites = Math.max(favorite_count, Global.max_favorites);

            Logger.debug("Calculating max_favorites:" + Global.max_favorites);

            Global.posts.push({
                div:comment_div, num_favs:favorite_count
            });
            Logger.debug("Calculated max_favorites:" + favorite_count);

            var id_text = comment_div_id + "_" + favorite_count;
            Logger.debug("id_text" + id_text);
            var all_id_text = comment_div_id + "_0";
            var show_all_span = document.createElement('span');
            show_all_span.className = "click_count";
            show_all_span.id = all_id_text;

            var show_count_span = document.createElement('span');
            show_count_span.className = "click_count";
            show_count_span.id = id_text;
            show_all_span.innerHTML = "&nbsp;<a>Show: all</a>";
            show_count_span.innerHTML = "&nbsp;<a> / " + favorite_count + " and above</a>";

            var show_more_span = document.createElement('span');
            show_more_span.innerHTML = "&nbsp;<a href='#posts'> / More options</a>";

            var flag_div = Util.getNodes('.//span[@id="flag' + site.fav_prefix + comment_div_id + '"]', comment_div)[0];
            Logger.debug("Inserting show all");
            flag_div.parentNode.insertBefore(show_all_span, flag_div);

            if (favorite_count > Global.min_count) {
                Logger.debug("Inserting show count");
                flag_div.parentNode.insertBefore(show_count_span, flag_div);
            }

            Logger.debug("Inserting show more options");
            flag_div.parentNode.insertBefore(show_more_span, flag_div);

        }
    }
    Logger.debug("Done looping through comments!");

    GM_addStyle('#posts { margin-bottom: 1em; }');

    GM_addStyle('.chart {'
        + 'background-color: ' + Global.table_bg_color + ';'
        + 'font: 14px sans-serif;'
        + 'margin: 0px 4px;'
        + 'color: black;'
        + 'border:1px solid white;'
        + 'border-collapse:collapse;'
        + '}');


    GM_addStyle('.comms {'
        + 'margin-left: 1em;'
        + 'float: left;'
        + 'width: 5%;'
        + '}');

    GM_addStyle('.favs {'
        + 'float: left;'
        + 'background-color: ' + Global.favorite_color + ';'
        + 'margin-right: 4px;'
        + 'text-align: center;'
        + '}');

    GM_addStyle('.wrapper {'
        + 'display: block;'
        + 'padding: 3px 0px;'
        + '}');

    GM_addStyle('.wrapperSelected {'
        + 'background-color: ' + Global.selected_color + ';'
        + '}');

    GM_addStyle('.wrapper:hover {'
        + 'background-color: ' + Global.hover_color + ';'
        + '}');

    GM_addStyle('.clearfix:after {'
        + 'content: ".";'
        + 'display: block;'
        + 'height: 0;'
        + 'clear: both;'
        + 'visibility: hidden;'
        + '}');

    Logger.debug("Done adding style.");

    initTable(counts);
    document.addEventListener('keydown', function (e) {
        // pressed alt+g
        if (e.keyCode == 71 && !e.shiftKey && !e.ctrlKey && e.altKey && !e.metaKey) {
            simulateClickShow(Global.row_prefix + 0);
        }
    }, false);

    var allClickClasses = getElementsByClassName(document, "click_count");
    Logger.debug("allClickClasses count: " + allClickClasses.length);

    for (var k = 0; k < allClickClasses.length; k++) {
        var n = allClickClasses[k];
        Logger.debug("n is: " + n);
        Logger.debug("n.target is: " + n.target);
        n.addEventListener('click', captureShowClick, false);
    }

    Logger.info("Loading MetaFilterFilterByFavorites is complete.");
}

/**
 * Generates the table at the top of the page
 * @param counts - Array of post counts, from 0 to Global.max_total. [fav_count => # of posts]
 * @return void
 */
function initTable(counts) {
    Logger.debug("Total counts: " + counts);

    var dummyDiv = document.createElement('div');
    var data_rows_html = '<div class="chart" style="width: 70%;">';
    var m = Global.max_count + 1, cum_comment_total = 0;
    // Generate the table rows
    while (m-- >= 0) {

        // we only show differences where the comment count has increased, or the very last row, showing all
        if (counts[m] > 0 || m == 0) {
            cum_comment_total += counts[m];

            var recommendedWidthSize = (Math.round((m / Global.max_favorites) * 90));

            data_rows_html += '<div id="' + Global.row_prefix + m + '" class="wrapper clearfix"><div class="comms">' + cum_comment_total + '</div>'
                + '<div class="favs" style="width: ' + recommendedWidthSize + '%;">(' + ((m == 0) ? "All" : m) + ')</div>'
                + '</div>';

        }
    }

    // Insert table into page

    Logger.debug("data_rows_html: " + data_rows_html);
    dummyDiv.innerHTML = '<div>'
        + '<div id="MultiFavoritesOptions" class="clearfix" style="white-space:nowrap; padding: 3px 0;">Show me this many comments (with at least this many favorites)</div>'
        + data_rows_html
        + '</div>';
    var page_div = document.getElementById("posts");
    page_div.insertBefore(dummyDiv.firstChild, page_div.firstChild);

    // Add the event listeners.
    var rows = Util.getNodes('.//div[@class="wrapper clearfix"]');
    var n = rows.length;
    Logger.debug("Found rows: " + n);

    while (n--) {
        Logger.debug("addEventListener");
        rows[n].addEventListener('click', filterPosts, false);
    }
}

init();