Lovely Forks

Show notable forks of GitHub projects.

// ==UserScript==
// @name         Lovely Forks
// @namespace    musically-ut
// @version      2.7.0
// @description  Show notable forks of GitHub projects.
// @homepage     https://github.com/musically-ut/lovely-forks
// @icon         https://github.com/musically-ut/lovely-forks/raw/master/userscript/icon.png
// @author       musically-ut
// @match        *://github.com/*
// @grant        GM_addStyle
// @run-at       document-end
// ==/UserScript==

/* WARNING: This usescript was created from a legacy version of Lovely Forks
 * which does not support user preferences setting and maintains code in pre-ES6
 * JavaScript format which ought to be supported by most browsers out there.
 * If you are using Firefox or Chrome, it is best to use the respective 
 * extension/addon for your browser for a better experience.
 */

'use strict';
/*jshint browser: true, es5: true, sub:true */

var script_css = `/* placeholder */
.repohead-details-container .public:after {
    display: block;
    visibility: hidden;

    content: ".";
    font-size: 12px;
    line-height: 12px;
}

/* remove placeholder */
.repohead-details-container .has-lovely-forks.public:after {
    display: none;
}

/* smoother integration, show hierarchy */
.lovely-forks-addon {
    animation: fade-in 0.2s;
    padding-left: 1em;
}

.lovely-fork-style {
    font-size: 12px;
}

@keyframes fade-in {
    from {
        opacity: 0;
    }
}
`;
GM_addStyle(script_css);

var _logName = 'lovely-forks:';
var DEBUG = false;
var text;

var svgNS = 'http://www.w3.org/2000/svg';

function createIconSVG(type) {
    var svg = document.createElementNS(svgNS, 'svg');
    svg.setAttributeNS(null, 'height', 12);
    svg.setAttributeNS(null, 'width', 10.5);
    svg.setAttributeNS(null, 'viewBox', '0 0 14 16');
    svg.style['vertical-align'] = 'bottom';
    svg.style['fill'] = 'currentColor';

    svg.classList.add('opticon', 'opticon-' + type);

    var title = document.createElementNS(svgNS, 'title');

    var iconPath = document.createElementNS(svgNS, 'path');
    switch(type) {
        case 'star':
            title.appendChild(document.createTextNode('Number of stars'));
            iconPath.setAttributeNS(null, 'd', 'M14 6l-4.9-0.64L7 1 4.9 5.36 0 6l3.6 3.26L2.67 14l4.33-2.33 4.33 2.33L10.4 9.26 14 6z');
            break;
        case 'flame':
            title.appendChild(document.createTextNode('Fork may be more recent than upstream.'));
            iconPath.setAttributeNS(null, 'd',  'M5.05 0.31c0.81 2.17 0.41 3.38-0.52 4.31-0.98 1.05-2.55 1.83-3.63 3.36-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-0.3-6.61-0.61 2.03 0.53 3.33 1.94 2.86 1.39-0.47 2.3 0.53 2.27 1.67-0.02 0.78-0.31 1.44-1.13 1.81 3.42-0.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52 0.13-2.03 1.13-1.89 2.75 0.09 1.08-1.02 1.8-1.86 1.33-0.67-0.41-0.66-1.19-0.06-1.78 1.25-1.23 1.75-4.09-1.88-6.22l-0.02-0.02z');
            iconPath.setAttributeNS(null, 'fill', '#d26911');
            break;
    }

    iconPath.appendChild(title);
    svg.appendChild(iconPath);

    return svg;
}

function emptyElem(elem) {
    elem.textContent = ''; // How jQuery does it
}

function mbStrToMs(dateStr) {
    return dateStr !== null ? Date.parse(dateStr) : null;
}

function isExpired(timeMs) {
    var currentTime = new Date();

    // The time of expiry of data is set to be an hour ago
    var expiryTimeMs = currentTime.valueOf() - 1000 * 60 * 60;
    return timeMs < expiryTimeMs;
}

function makeSelfDataKey(user, repo) {
    return 'lovely-forks@self:' + user + '/' + repo;
}

function makeRemoteDataKey(user, repo) {
    return 'lovely-forks@remote:' + user + '/' + repo;
}


var reDateKey = new RegExp('^lovely-forks@date:(.*)/(.*)$');
function makeTimeKey(user, repo) {
    return 'lovely-forks@date:' + user + '/' + repo;
}

function parseTimeKey(key) {
    var match = reDateKey.exec(key);
    if (match !== null) {
        return [match[1], match[2]];
    } else {
        return null;
    }
}

function getForksElement() {
    // Verify that the element exists and it's still valid
    // otherwise, create it
    if (document.body.contains(text)) {
        return text;
    }

    // If the layout of the page changes, we'll have to change this location.
    // We should make sure that we do not accidentally cause errors here.
    var repoName = document.querySelector('strong[itemprop="name"]').parentNode;
    if (repoName) {
        try {
            text = document.createElement('span');

            text.classList.add('lovely-fork-style', 'lovely-forks-addon');

            repoName.appendChild(text);

            return text;
        } catch (e) {
            console.error(_logName,
                          'Error appending data to DOM',
                          e);
        }
    } else {
        console.warn(_logName,
                     'Looks like the layout of the GitHub page has changed.');
    }
}

function clearLocalStorage() {
    var keysToUnset = [];

    /* Remove all items which have expired. */
    for(var ii = 0; ii < localStorage.length; ii++) {
        var key = localStorage.key(ii);
        var mbUserRepo = parseTimeKey(key);
        if (mbUserRepo !== null) {

            var timeMs = mbStrToMs(localStorage.getItem(key));

            if (timeMs) {
                if (isExpired(timeMs)) {
                    var user = mbUserRepo[0],
                        repo = mbUserRepo[1];
                    keysToUnset.push(makeRemoteDataKey(user, repo));
                    keysToUnset.push(makeSelfDataKey(user, repo));
                    keysToUnset.push(makeTimeKey(user, repo));
                }
            } else {
                console.warn(_logName,
                             'Unable to parse time: ',
                             localStorage.getItem(key));
            }
        }
    }

    keysToUnset.forEach(function (key) {
        if (DEBUG) {
            console.log(_logName,
                        'Removing key: ', key);
        }
        localStorage.removeItem(key);
    });
}

function safeUpdateDOM(action, actionName) {
    // Get the stored version or create it if it doesn't exist
    var text = getForksElement();

    // We should make sure that we do not accidentally cause errors here.
    if (text) {
        try {
            emptyElem(text);
            action(text);
        } catch (e) {
            console.error(_logName,
                          'Error appending data to DOM', e,
                          'during action', actionName);
        }
    } else {
        console.warn(_logName,
                     'Unable to find the lovely-forks loading indicator',
                     'during action', actionName);
    }
}

function showDetails(fullName, url, numStars, remoteIsNewer) {
    return function (text) {
        var forkA = document.createElement('a');
        forkA.href = url;
        forkA.appendChild(document.createTextNode(fullName));

        text.appendChild(document.createTextNode('also forked to '));
        text.appendChild(forkA);
        text.appendChild(document.createTextNode(' '));
        text.appendChild(createIconSVG('star'));
        text.appendChild(document.createTextNode('' + numStars + ' '));

        if (remoteIsNewer) {
            text.appendChild(createIconSVG('flame'));
        }

        text.parentNode.classList.add('has-lovely-forks');
    };
}

function makeRemoteDataURL(user, repo) {
    return 'https://api.github.com/repos/' +
                  user + '/' + repo + '/forks?sort=stargazers';
}

function makeCommitDiffURL(user, repo, remoteUser, default_branch) {
    return 'https://api.github.com/repos/' +
                  user + '/' + repo + '/compare/' +
                  user + ':' + default_branch + '...' +
                  remoteUser + ':' + default_branch;
}


// From: http://crocodillon.com/blog/always-catch-localstorage-security-and-quota-exceeded-errors
function isQuotaExceeded(e) {
    var quotaExceeded = false;
    if (e) {
        if (e.code) {
            switch (e.code) {
                case 22:
                    quotaExceeded = true;
                    break;
                case 1014:
                    // Firefox
                    if (e.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
                        quotaExceeded = true;
                    }
                    break;
            }
        }
    }
    return quotaExceeded;
}

function processWithData(user, repo, remoteDataStr, selfDataStr, isFreshData) {
    try {
        /* Parse fork data */
        /* Can either be just one data element,
         * or could be the list of all forks. */
        var allForksData = JSON.parse(remoteDataStr);
        var mostStarredFork = allForksData[0];

        var forkUrl = mostStarredFork['html_url'],
            fullName = mostStarredFork['full_name'];

        /* Parse self data */
        /* This could either be the commit-diff data (v2)
         * or `all_commits` data (v1). */
        /* selfData can also be null, if the commit difference API resulted in
         * an error. */
        var selfData = JSON.parse(selfDataStr),
            selfDataToSave = selfData,
            remoteIsNewer = false;

        if (selfData !== null) {
            if (selfData.hasOwnProperty('ahead_by')) {
                // New version
                var diffData = selfData;
                remoteIsNewer = (diffData['ahead_by'] - diffData['behind_by']) > 0;
            } else {
                // Old version
                var allCommits = selfData;
                var remoteUpdateTimeMs = mbStrToMs(mostStarredFork['pushed_at']);

                if (!allCommits || allCommits.length < 1) {
                    if (DEBUG) {
                        console.log(_logName,
                                    'Repository does not have any commits.');
                    }
                    return;
                }

                var latestCommit = allCommits[0]['commit'];
                var committer = latestCommit['committer'];

                if (!committer) {
                    if (DEBUG) {
                        console.error(_logName,
                                      'Could not find the latest committer.');
                    }
                    return;
                }

                var selfUpdateTimeMs = mbStrToMs(committer['date']);

                remoteIsNewer = remoteUpdateTimeMs > selfUpdateTimeMs;
                selfDataToSave = [allCommits[0]];
            }
        } else {
            remoteIsNewer = false;
        }

        /* Cache data, if necessary */
        if (isFreshData) {
            var currentTimeMs = (new Date()).toString();

            if (DEBUG) {
                console.log(_logName, 'Saving data');
            }

            try {
                clearLocalStorage();
                localStorage.setItem(makeTimeKey(user, repo), currentTimeMs);

                // Only the most starred fork is relevant
                var relevantRemoteDataStr = JSON.stringify([mostStarredFork]);
                localStorage.setItem(makeRemoteDataKey(user, repo),
                                     relevantRemoteDataStr);

                // Only the latest commit is relevant
                var relevantSelfDataStr = JSON.stringify(selfDataToSave);
                localStorage.setItem(makeSelfDataKey(user, repo),
                                     relevantSelfDataStr);
            } catch(e) {
                if (isQuotaExceeded(e)) {
                    console.warn(_logName, 'localStorage quota full.');
                } else {
                    throw e;
                }
            }
        }

        // Now if the repository doesn't have any notable forks, so not
        // touch the DOM.
        var starGazers = mostStarredFork['stargazers_count'];

        if (!starGazers) {
            if (DEBUG) {
                console.log(_logName,
                            'Repo has only zero starred forks.');
            }
            return;
        }

        safeUpdateDOM(showDetails(fullName, forkUrl, starGazers,
                                  remoteIsNewer),
                      'showing details');
    } catch (e) {
        console.warn(_logName,
                     'Error while handling response: ',
                     e);
    }
}

function onreadystatechangeFactory(xhr, successFn) {
    return function () {
        if (xhr.readyState === 4) {
            if (xhr.status === 200) {
                successFn();
            } else if (xhr.status === 403) {
                console.warn(_logName,
                             'Looks like the rate-limit was exceeded.');
            } else {
                console.warn(_logName,
                             'GitHub API returned status:', xhr.status);
            }
        } else {
            // Request is still in progress
            // Do nothing.
        }
    };
}

function makeFreshRequest(user, repo) {
    var xhrFork = new XMLHttpRequest();

    xhrFork.onreadystatechange = onreadystatechangeFactory(
        xhrFork,
        function () {
            var forksDataJson = JSON.parse(xhrFork.responseText);
            if (!forksDataJson || forksDataJson.length === 0) {
                if (DEBUG) {
                    console.log(_logName,
                                'Repository does not have any forks.');
                }
                return;
            }

            var mostStarredFork = forksDataJson[0],
                forksDataStr = JSON.stringify([mostStarredFork]);

            var defaultBranch = mostStarredFork['default_branch'],
                remoteUser    = mostStarredFork['owner']['login'];

            var xhrDiff = new XMLHttpRequest();

            xhrDiff.onreadystatechange = function () {
                if (xhrDiff.readyState === 4) {
                    if (xhrDiff.status === 200) {
                        var commitDiffJson = JSON.parse(xhrDiff.responseText);
                        // Dropping the list of commits to conserve space.
                        commitDiffJson['commits'] = [];
                        var commitDiffStr = JSON.stringify(commitDiffJson);
                        processWithData(user, repo, forksDataStr, commitDiffStr, true);
                    } else {
                        // In case of any error, ignore recency data.
                        processWithData(user, repo, forksDataStr, null, true);
                    }
                }
            };

            xhrDiff.open('GET', makeCommitDiffURL(user, repo, remoteUser, defaultBranch));
            xhrDiff.send();
        }
    );

    xhrFork.open('GET', makeRemoteDataURL(user, repo));
    xhrFork.send();
}

function getDataFor(user, repo) {
    var lfTimeKey       = makeTimeKey(user, repo),
        lfRemoteDataKey = makeRemoteDataKey(user, repo),
        lfSelfDataKey   = makeSelfDataKey(user, repo);

    var ret = { hasData: false };

    var savedRemoteDataStr = localStorage.getItem(lfRemoteDataKey);
    var savedSelfDataStr   = localStorage.getItem(lfSelfDataKey);
    var saveTimeMs         = mbStrToMs(localStorage.getItem(lfTimeKey));

    if (saveTimeMs         === null ||
        savedRemoteDataStr === null ||
        savedSelfDataStr   === null) {
        return ret;
    }

    ret.hasData            = true;
    ret.saveTimeMs         = saveTimeMs;
    ret.savedRemoteDataStr = savedRemoteDataStr;
    ret.savedSelfDataStr   = savedSelfDataStr;

    return ret;
}

function runFor(user, repo) {
    try {
        var cache = getDataFor(user, repo);
        if (cache.hasData && !isExpired(cache.saveTimeMs)) {
            if (DEBUG) {
                console.log(_logName,
                            'Reusing saved data.');
            }
            processWithData(user, repo,
                            cache.savedRemoteDataStr, cache.savedSelfDataStr,
                            false);
        } else {
            if (DEBUG) {
                console.log(_logName,
                            'Requesting the data from GitHub API.');
            }
            makeFreshRequest(user, repo);
        }
    } catch (e) {
        console.error(_logName, 'Could not run for ', user + '/' + repo,
                      'Exception: ', e);
    }
}

/* Script execution */

var pathComponents = window.location.pathname.split('/');
if (pathComponents.length >= 3) {
    var user = pathComponents[1], repo = pathComponents[2];
    runFor(user, repo);
} else {
    if (DEBUG) {
        console.log(_logName,
                    'The URL did not identify a username/repository pair.');
    }
}