// ==UserScript==
// @name Useful Forks
// @author payne911, odnar-dev
// @version 1.8
// @license MIT
// @namespace https://github.com/community-plugins/Userscripts
// @description Displays GitHub forks ordered by stars, and with additional information and automatic filters.
// @match *://github.com/*/*
// @grant GM_openInTab
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @require https://code.jquery.com/jquery-3.5.1.min.js
// @icon https://useful-forks.github.io/assets/useful-forks-logo.png
// @homepageURL https://github.com/community-plugins/Userscripts/tree/main/Useful%20Forks
// @supportURL https://github.com/community-plugins/Userscripts/labels/Useful%20Forks
// ==/UserScript==
(function() {
let GITHUB_ACCESS_TOKEN = GM_getValue('GITHUB_ACCESS_TOKEN')
GM_registerMenuCommand("Set Github Access Token", setPersonalToken)
GM_registerMenuCommand("Generate New Access Token", newPersonalToken);
function setPersonalToken(){
var mess = "Personal Access Token";
var caseShow = GITHUB_ACCESS_TOKEN;
var getpersonalToken = prompt(mess, caseShow);
GITHUB_ACCESS_TOKEN = (getpersonalToken === null? GITHUB_ACCESS_TOKEN : getpersonalToken)
GM_setValue("GITHUB_ACCESS_TOKEN", GITHUB_ACCESS_TOKEN)
}
function newPersonalToken(){
let tabControl = GM_openInTab("https://github.com/settings/tokens/new?description=useful-forks%20(no%20scope%20required)")
tabControl.onclose = () => setPersonalToken();
}
function valid(string) {
return string && string.length > 0;
}
const UF_ID_WRAPPER = 'useful_forks_wrapper';
const UF_ID_TITLE = 'useful_forks_title';
const UF_ID_MSG = 'useful_forks_msg';
const UF_ID_DATA = 'useful_forks_data';
const UF_ID_TABLE = 'useful_forks_table';
const svg_literal_fork = '<svg class="octicon octicon-repo-forked v-align-text-bottom" viewBox="0 0 10 16" width="10" height="16" aria-hidden="true" role="img"><title>Amount of forks, or name of the repository</title><path fill-rule="evenodd" d="M8 1a1.993 1.993 0 00-1 3.72V6L5 8 3 6V4.72A1.993 1.993 0 002 1a1.993 1.993 0 00-1 3.72V6.5l3 3v1.78A1.993 1.993 0 005 15a1.993 1.993 0 001-3.72V9.5l3-3V4.72A1.993 1.993 0 008 1zM2 4.2C1.34 4.2.8 3.65.8 3c0-.65.55-1.2 1.2-1.2.65 0 1.2.55 1.2 1.2 0 .65-.55 1.2-1.2 1.2zm3 10c-.66 0-1.2-.55-1.2-1.2 0-.65.55-1.2 1.2-1.2.65 0 1.2.55 1.2 1.2 0 .65-.55 1.2-1.2 1.2zm3-10c-.66 0-1.2-.55-1.2-1.2 0-.65.55-1.2 1.2-1.2.65 0 1.2.55 1.2 1.2 0 .65-.55 1.2-1.2 1.2z"></path></svg>';
const svg_literal_star = '<svg class="octicon octicon-star v-align-text-bottom" viewBox="0 0 14 16" width="14" height="16" aria-label="star" role="img"><title>Amount of stars</title><path fill-rule="evenodd" d="M14 6l-4.9-.64L7 1 4.9 5.36 0 6l3.6 3.26L2.67 14 7 11.67 11.33 14l-.93-4.74L14 6z"></path></svg>';
const svg_literal_date = '<svg class="octicon octicon-history text-gray" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" role="img"><title>Date of the most recent push in ANY branch of the repository</title><path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"></path></svg>';
const UF_MSG_HEADER = "<b>Useful forks</b>";
const UF_MSG_NO_FORKS = "No one forked this specific repository.";
const UF_MSG_SCANNING = "Currently scanning all the forks.";
const UF_MSG_ERROR = "There seems to have been an error while scanning forks.";
const UF_MSG_EMPTY_FILTER = "All the forks have been filtered out: you can now rest easy!";
const UF_MSG_API_RATE = "<b>Exceeded GitHub API rate-limits.</b>";
const UF_TABLE_SEPARATOR = "|";
const UF_MSG_ACCESS_TOKEN = 'You need to provide a personal Access Token.<br> If you don\'t already have one, you can create one now by clicking <a href="https://github.com/settings/tokens/new?description=useful-forks%20(no%20scope%20required)" target="_blank">here</a>';
const FORKS_PER_PAGE = 100; // enforced by GitHub API
let REQUESTS_COUNTER = 0; // to know when it's over
function allRequestsAreDone() {
return REQUESTS_COUNTER <= 0;
}
function checkIfAllRequestsAreDone() {
if (allRequestsAreDone()) {
sortTable();
}
}
function getOnlyDate(full) {
return full.split('T')[0];
}
function extract_username_from_fork(combined_name) {
return combined_name.split('/')[0];
}
function badge_width(number) {
return 70 * number.toString().length; // magic number 70 extracted from analyzing 'shields.io'
}
/** Credits to https://shields.io/ */
function ahead_badge(amount) {
return '<svg xmlns="http://www.w3.org/2000/svg" width="88" height="24" role="img"><title>How far ahead this fork\'s default branch is compared to its parent\'s default branch</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset=".1" stop-color="#aaa" stop-opacity=".1"/><stop offset=".9" stop-color="#000" stop-opacity=".3"/><stop offset="1" stop-color="#000" stop-opacity=".5"/></linearGradient><clipPath id="r"><rect width="88" height="18" rx="4" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="43" height="18" fill="#555"/><rect x="43" width="45" height="18" fill="#007ec6"/><rect width="88" height="18" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="225" y="140" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">ahead</text><text x="225" y="130" transform="scale(.1)" fill="#fff" textLength="330">ahead</text><text x="645" y="130" transform="scale(.1)" fill="#fff" textLength="' + badge_width(amount) + '">' + amount + '</text></g></svg>';
}
/** Credits to https://shields.io/ */
function behind_badge(amount) {
const color = amount === 0 ? '#4c1' : '#007ec6'; // green only when not behind, blue otherwise
return '<svg xmlns="http://www.w3.org/2000/svg" width="92" height="24" role="img"><title>How far behind this fork\'s default branch is compared to its parent\'s default branch</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#fff" stop-opacity=".7"/><stop offset=".1" stop-color="#aaa" stop-opacity=".1"/><stop offset=".9" stop-color="#000" stop-opacity=".3"/><stop offset="1" stop-color="#000" stop-opacity=".5"/></linearGradient><clipPath id="r"><rect width="92" height="18" rx="4" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="47" height="18" fill="#555"/><rect x="47" width="45" height="18" fill="'+ color +'"/><rect width="92" height="18" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="245" y="140" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">behind</text><text x="245" y="130" transform="scale(.1)" fill="#fff" textLength="370">behind</text><text x="685" y="130" transform="scale(.1)" fill="#fff" textLength="' + badge_width(amount) + '">' + amount + '</text></g></svg>';
}
function getElementById_$(id) {
return $('#' + id);
}
function isEmpty(aList) {
return (!aList || aList.length === 0);
}
function setMsg(msg) {
getElementById_$(UF_ID_MSG).html(msg);
}
function clearMsg() {
setMsg("");
}
function getTableBody() {
return getElementById_$(UF_ID_TABLE).find($("tbody"));
}
function getTdValue(rows, index, col) {
return Number(rows.item(index).getElementsByTagName('td').item(col).getAttribute("value"));
}
function sortTable() {
sortTableColumn(UF_ID_TABLE, 1);
}
/** 'sortColumn' index starts at 0. https://stackoverflow.com/a/37814596/9768291 */
function sortTableColumn(table_id, sortColumn){
let tableData = document.getElementById(table_id).getElementsByTagName('tbody').item(0);
let rows = tableData.getElementsByTagName('tr');
for(let i = 0; i < rows.length - 1; i++) {
for(let j = 0; j < rows.length - (i + 1); j++) {
if(getTdValue(rows, j, sortColumn) < getTdValue(rows, j+1, sortColumn)) {
tableData.insertBefore(rows.item(j+1), rows.item(j));
}
}
}
}
/** The secondary request which appends the badges. */
function commits_count(request, table_body, table_row, pushed_at) {
return () => {
const response = JSON.parse(request.responseText);
if (response.total_commits === 0) {
table_row.remove();
if (table_body.children().length === 0) {
setMsg(UF_MSG_EMPTY_FILTER);
}
} else {
table_row.append(
$('<td>').html(UF_TABLE_SEPARATOR),
$('<td>', {class: "uf_badge"}).html(ahead_badge(response.ahead_by)),
$('<td>').html(UF_TABLE_SEPARATOR),
$('<td>', {class: "uf_badge"}).html(behind_badge(response.behind_by)),
$('<td>').html(UF_TABLE_SEPARATOR + svg_literal_date + ' ' + pushed_at)
);
}
/* Detection of final request. */
REQUESTS_COUNTER--;
checkIfAllRequestsAreDone();
}
}
/** To remove erroneous repos. */
function commits_count_failure(table_row) {
return () => {
table_row.remove();
/* Detection of final request. */
REQUESTS_COUNTER--;
checkIfAllRequestsAreDone();
}
}
/** To use the Access Token with a request. */
function authenticatedRequestHeaderFactory(url) {
let request = new XMLHttpRequest();
request.open('GET', url);
request.setRequestHeader("Accept", "application/vnd.github.v3+json");
request.setRequestHeader("Authorization", "token " + GITHUB_ACCESS_TOKEN);
return request;
}
/** Defines the default behavior of a request. */
function onreadystatechangeFactory(xhr, successFn, failureFn) {
return () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
successFn();
} else if (xhr.status === 403) {
console.warn('Looks like the rate-limit was exceeded.');
setMsg(UF_MSG_API_RATE);
} else {
console.warn('GitHub API returned status:', xhr.status);
failureFn();
}
} else {
// Request is still in progress
}
};
}
/** Fills the first part of the rows. */
function build_fork_element_html(table_body, combined_name, num_stars, num_forks) {
const NEW_ROW = $('<tr>', {id: extract_username_from_fork(combined_name), class: "useful_forks_repo"});
table_body.append(
NEW_ROW.append(
$('<td>').html(svg_literal_fork + ` <a href="https://github.com/${combined_name}" target="_blank" rel="noopener noreferrer">${combined_name}</a>`),
$('<td>').html(UF_TABLE_SEPARATOR + svg_literal_star + ' x ' + num_stars).attr("value", num_stars),
$('<td>').html(UF_TABLE_SEPARATOR + svg_literal_fork + ' x ' + num_forks).attr("value", num_forks)
)
);
return NEW_ROW;
}
/** Prepares, appends, and updates dynamically a table row. */
function add_fork_elements(forkdata_array, user, repo, parentDefaultBranch) {
if (isEmpty(forkdata_array))
return;
clearMsg();
let table_body = getTableBody();
for (let i = 0; i < forkdata_array.length; ++i) {
const currFork = forkdata_array[i];
/* Basic data (stars, watchers, forks). */
const NEW_ROW = build_fork_element_html(table_body, currFork.full_name, currFork.stargazers_count, currFork.forks_count);
/* Commits diff data (ahead/behind). */
const API_REQUEST_URL = `https://api.github.com/repos/${user}/${repo}/compare/${parentDefaultBranch}...${extract_username_from_fork(currFork.full_name)}:${currFork.default_branch}`;
let request = authenticatedRequestHeaderFactory(API_REQUEST_URL);
request.onreadystatechange = onreadystatechangeFactory(request, commits_count(request, table_body, NEW_ROW, getOnlyDate(currFork.pushed_at)), commits_count_failure(NEW_ROW));
request.send();
/* Forks of forks. */
if (currFork.forks_count > 0) {
request_fork_page(1, currFork.owner.login, currFork.name, currFork.default_branch);
}
}
}
/** Paginated request. Pages index start at 1. */
function request_fork_page(page_number, user, repo, defaultBranch) {
const API_REQUEST_URL = `https://api.github.com/repos/${user}/${repo}/forks?sort=stargazers&per_page=${FORKS_PER_PAGE}&page=${page_number}`;
let request = authenticatedRequestHeaderFactory(API_REQUEST_URL);
request.onreadystatechange = onreadystatechangeFactory(request,
() => {
const response = JSON.parse(request.responseText);
/* On empty response (repo has not been forked). */
if (isEmpty(response))
return;
REQUESTS_COUNTER += response.length; // to keep track of when the query ends
/* Pagination (beyond 100 forks). */
const link_header = request.getResponseHeader("link");
if (link_header) {
let contains_next_page = link_header.indexOf('>; rel="next"');
if (contains_next_page !== -1) {
request_fork_page(++page_number, user, repo, defaultBranch);
}
}
sortTable();
/* Populate the table. */
add_fork_elements(response, user, repo, defaultBranch);
},
() => {
setMsg(UF_MSG_ERROR);
checkIfAllRequestsAreDone();
});
request.send();
}
/** Updates header with Queried Repo info, and initiates recursive forks search */
function initial_request(user, repo) {
const API_REQUEST_URL = `https://api.github.com/repos/${user}/${repo}`;
let request = authenticatedRequestHeaderFactory(API_REQUEST_URL);
request.onreadystatechange = onreadystatechangeFactory(request,
() => {
const response = JSON.parse(request.responseText);
if (isEmpty(response))
return;
if (response.forks_count > 0) {
request_fork_page(1, user, repo, response.default_branch);
} else {
setMsg(UF_MSG_NO_FORKS);
enableQueryFields();
}
},
() => setMsg(UF_MSG_ERROR)
);
request.send();
}
function prepare_display() {
$('#network').prepend(
$('<div>', {id: UF_ID_WRAPPER, class: "float-right"}).append(
$('<h4>', {id: UF_ID_TITLE, html: UF_MSG_HEADER}),
$('<div>', {id: UF_ID_MSG, html: UF_MSG_SCANNING}),
$('<div>', {id: UF_ID_DATA}).append(
$('<table>', {id: UF_ID_TABLE}).append(
$('<tbody>')
)
)
)
);
}
/** To determine if Dark Mode is enabled. */
function getGitHubTheme() {
let colorMode = document.querySelector('[data-color-mode]')?.dataset.colorMode;
if (colorMode === 'dark') {
return "dark";
} else if (colorMode === 'auto') {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return "dark";
}
}
return "light"; // default
}
function add_css() {
const GITHUB_THEME = getGitHubTheme();
const TR_HOVER_COLOR = GITHUB_THEME === "dark" ? '#2f353e' : '#e2e2e2';
const TR_BG_COLOR = GITHUB_THEME === "dark" ? '#161b22' : '#f5f5f5';
const ADDITIONAL_CSS = `
.uf_badge svg {
display: table-cell;
padding-top: 3px;
}
tr:hover {background-color: ${TR_HOVER_COLOR} !important;}
tr:nth-child(even) {background-color: ${TR_BG_COLOR};}
#${UF_ID_MSG} {color: red;}
`;
let styleSheet = document.createElement('style');
styleSheet.type = "text/css";
styleSheet.innerText = ADDITIONAL_CSS;
document.head.appendChild(styleSheet);
}
/** Entry point. */
function init() {
const pathComponents = window.location.pathname.split('/');
if (pathComponents.length >= 3) {
if (pathComponents[4] === "members") {
const user = pathComponents[1], repo = pathComponents[2];
add_css();
prepare_display();
// only call if GITHUB_ACCESS_TOKEN has been set up
if (valid(GITHUB_ACCESS_TOKEN)) {
initial_request(user, repo);
} else {
setMsg(UF_MSG_ACCESS_TOKEN);
}
}
}
}
init();
//When navigating between Insights pages, URL is manipulated through PJAX.
document.addEventListener('pjax:end', init);
document.addEventListener('turbo:render', init);
})();