Rank HN comments by engagement

Ranks HN comments by number of direct or indirect children, or by custom criteria

// ==UserScript==
// @name         Rank HN comments by engagement
// @description  Ranks HN comments by number of direct or indirect children, or by custom criteria
// @version      1
// @author       heartbeats
// @namespace    https://news.ycombinator.com/user?id=heartbeats
// @include      *://news.ycombinator.com/*
// ==/UserScript==

(function() {
    'use strict';
    /* Change this variable to change the ranking algorithm. 
     * Presently available ranking algorithms are:
     *   bump: Whenever someone comments on a post, the entire tree gets moved to the top. Like old forums. Tiebreaker: hn_engagement
     *   count: Most children first. New comments without children go on the bottom.
     *   hn_engagement: One child (direct or indirect) is treated as 1 vote, HN's standard ranking algorithm is then applied
     */
    let SORT_METHOD = 'bump';
    let ageCache = {};

    function sortOp() {
        if (SORT_METHOD === 'bump')
            return 'max';
        else
            return 'add';
    }

    function rankComment(age, sortMethod) {
        // TODO: https://news.ycombinator.com/item?id=1782473
        // Rank. Higher is better.
        let gravity = 1.8;
        let timeBase = 7200; // from https://news.ycombinator.com/item?id=1781417
        console.assert(age !== undefined);
        switch (sortMethod) {
            case 'bump': return age; break;
            case 'count': return 1; break;
            case 'hn_engagement': return 1.0 / Math.pow(-age + timeBase, gravity); break;
            default: console.assert(false); break;
        };
    }

    function getIndent(id) {
        let iwidth = 40; // hardcoded in document HTML
        return Math.round(document.getElementById(id).querySelector(".ind > img").width/iwidth);
    }

    function getAge(id) {
        // relative time, in negative seconds
        let timeHuman = {
            minute: 60,
            hour: 3600,
            day: 86400
        };
        
        let text = document.getElementById(id).querySelector(".age").textContent;
        let tokenized = text.split(' ');
        console.assert((tokenized.length == 3 && tokenized[2] === "ago") ||
                       (tokenized.length == 4 &&  tokenized[0] === "on"));
        let rel = tokenized.length == 3;
        if (rel)
            return -tokenized[0] * timeHuman[/^.*[^s]/.exec(tokenized[1])];
        else
            return (Date.parse(tokenized.slice(1,).join(' ')) - Date.now())/1000;
    }

    function parseList(list) {
        let ret = [];
        let nodes = {};
        nodes[0] = 0;
        for (let i = 0; i < list.length; i++) {
            let ind = getIndent(list[i]);
            ret.push([list[i], nodes[ind-1]]); // set to ID of last ancestor
            nodes[ind] = list[i]; // prepare for children
        }
        return ret;
    }

    function flattenTree(tree) {
        // Get (sorted) all comments in tree
        let list = [tree.name];
        for (let i = 0; i < tree.children.length; i++) {
            list = list.concat(flattenTree(tree.children[i]));
        }
        return list;
    }

    function rankComments(subtree, sortMethod) {
        // tree's rank is sum or max of all children's rank
        // TODO: decay by say 0.8 for each level
        let reductions = {max: (a, b) => (a > b) ? a : b,
                          add: (a, b) =>  a + b};
        let rc = flattenTree(subtree).flatMap(id => (id == null) ? [] : rankComment(getAge(id), sortMethod)).reduce(reductions[sortOp()]);
        return rc;
    }

    function compareTree(a, b) {
        let rank_a = rankComments(a, SORT_METHOD);
        let rank_b = rankComments(b, SORT_METHOD);
        if (rank_a == rank_b) {
            /* HN's times aren't very granular, especially for old posts.
             * We thus use hn_engagement as a tiebreaker.
             * This converges to "number of descendants" for old posts.
             */
            rank_a = rankComments(a, 'hn_engagement');
            rank_b = rankComments(b, 'hn_engagement');
        }
        if (rank_a == rank_b)
            return 0;
        if (rank_a < rank_b)
            return 1;
        if (rank_a > rank_b)
            return -1;
        // TODO: stack
    }

    function appendSubtree(arg, ptr, item) {
        // this inserts an item into a tree
        // tree structure:
        // {children: [
        //   {name: 123, children: undefined},
        //   {name: 124, children: [
        //    {name: 125, children: undefined
        //   ]}
        //  ]
        // }
        // TODO refactor
        let idx = 0;
        console.assert(ptr !== undefined);
        if (ptr.length > 0)
            [arg.children[ptr[0]], idx] = appendSubtree(arg.children[ptr[0]], ptr.slice(1,), item);
        else
            idx = arg.children.push({name: item, children: []}) - 1;
        return [arg, idx];
    }

    function listToTree(list) {
        // will only append
        let tree = {children: []};
        let idx2root = {}; // ID: [3, 1, 4] = 3rd comment's 1st child's 4th child
        for (let i = 0; i < list.length; i++) {
            let subtree_ptr = [];
            let id = list[i][0];
            parent = list[i][1];
            if (parent !== undefined) {
                console.assert(idx2root[parent]);
                subtree_ptr = idx2root[parent];
                console.assert(subtree_ptr !== undefined);
            }
            let last_ptr; // This is the lowest idx of the appended comment
            console.assert(subtree_ptr !== undefined);
            [tree, last_ptr] = appendSubtree(tree, subtree_ptr, id);
            idx2root[id] = ((parent === undefined) ? [] : idx2root[parent]).concat(last_ptr);
        }
        return tree;
    }

    function getIndent(id) {
        let iwidth = 40;
        return Math.round(document.getElementById(id).querySelector(".ind > img").width/iwidth);
    }

    function parseList(list) {
        let ret = []
        let nodes = {}
        nodes[0] = undefined;
        for (let i = 0; i < list.length; i++) {
            let id = list[i];
            let ind = getIndent(id);
            ret.push([id, nodes[ind-1]]); // set to ID of last ancestor
            nodes[ind] = id; // prepare for children
        }
        return ret;
    }

    function getComments() {
        return document.getElementsByClassName("comment-tree")[0].children[0].children;
    }

    function getCommentsList(comments) {
        let l = [];

        for (let i = 0; i < comments.length; i++)
            l.push(comments[i].id);
        return l;
    }

    function treeSort(tree, compar) {
        for (let i = 0; i < tree.children.length; i++) {
                tree.children[i] = treeSort(tree.children[i], compar);
        }
        tree.children = tree.children.sort(compar);
        return tree;
    }

    function sortComments() {
        //TODO: hide comments
        let comments = getComments();
        let commentsEl = document.getElementsByClassName("comment-tree")[0].children[0]; // TODO
        let list = getCommentsList(comments);
        let tree = parseList(list); // bottom-up tree
        let tree2 = listToTree(tree); // real tree

        tree2 = treeSort(tree2, compareTree);
        let flat = flattenTree(tree2);
        for (let i = 0; i < flat.length; i++) {
            let child = document.getElementById(flat[i]);
            if (child != null) commentsEl.appendChild(child);
        }

        //TODO: Show the comments again
    }

    sortComments();
})();