GitHub Sort Content

A userscript that makes some lists & markdown tables sortable

Ekde 2019/09/02. Vidu La ĝisdata versio.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

  1. // ==UserScript==
  2. // @name GitHub Sort Content
  3. // @version 3.0.0
  4. // @description A userscript that makes some lists & markdown tables sortable
  5. // @license MIT
  6. // @author Rob Garrison
  7. // @namespace https://github.com/Mottie
  8. // @include https://github.com/*
  9. // @include https://gist.github.com/*
  10. // @run-at document-idle
  11. // @grant GM.addStyle
  12. // @grant GM_addStyle
  13. // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js?updated=20180103
  14. // @require https://cdnjs.cloudflare.com/ajax/libs/tinysort/2.3.6/tinysort.min.js
  15. // @require https://greatest.deepsurf.us/scripts/28721-mutations/code/mutations.js?version=666427
  16. // @icon https://github.githubassets.com/pinned-octocat.svg
  17. // ==/UserScript==
  18. /* global tinysort */
  19. (() => {
  20. "use strict";
  21. /** Example pages:
  22. * Tables (Readme & wikis) - https://github.com/Mottie/GitHub-userscripts
  23. * Repo files table - https://github.com/Mottie/GitHub-userscripts (sort content, message or age)
  24. * Activity - https://github.com (recent & all)
  25. * Sidebar - https://github.com/ (Repositories & Your teams)
  26. * Pinned repos (user & org)- https://github.com/(:user|:org)
  27. * Org Repos - https://github.com/:org
  28. * Org people - https://github.com/orgs/:org/people
  29. * Org outside collaborators (own orgs) - https://github.com/orgs/:org/outside-collaborators
  30. * Org teams - https://github.com/orgs/:org/teams & https://github.com/orgs/:org/teams/:team/teams
  31. * Org team repos - https://github.com/orgs/:org/teams/:team/repositories
  32. * Org team members - https://github.com/orgs/:org/teams/:team/members
  33. * Org projects - https://github.com/:org/projects
  34. * User repos - https://github.com/:user?tab=repositories
  35. * User stars - https://github.com/:user?tab=stars
  36. * User Followers - https://github.com/:user?tab=followers & https://github.com/:user/followers(/you_know)
  37. * User Following - https://github.com/:user?tab=following & https://github.com/:user/following(/you_know)
  38. * watching - https://github.com/watching
  39. * Repo stargazers - https://github.com/:user/:repo/stargazers
  40. * Repo watchers - https://github.com/:user/:repo/watchers
  41. */
  42. /**
  43. * sortables[entry].setup - exec on userscript init (optional)
  44. * sortables[entry].check - exec on doc.body click; return truthy/falsy or
  45. * header element (passed to the sort)
  46. * sortables[entry].sort - exec if check returns true or a header element;
  47. * el param is the element returned by check or original click target
  48. * sortables[entry].css - specific css as an array of selectors, applied to
  49. * the entry elements; "unsorted", "asc" (optional), "desc" (optional),
  50. * "tweaks" (optional)
  51. */
  52. const sortables = {
  53. // markdown tables
  54. "tables": {
  55. // init after a short delay to allow rendering of file list
  56. setup: () => setTimeout(() => addRepoFileThead(), 200),
  57. check: el => el.nodeName === "TH" &&
  58. el.matches(".markdown-body table thead th, table.files thead th"),
  59. sort: el => initSortTable(el),
  60. css: {
  61. unsorted: [
  62. ".markdown-body table thead th",
  63. ".markdown-body table.csv-data thead th",
  64. "table.files thead th"
  65. ],
  66. tweaks: [
  67. `body .markdown-body table thead th, body table.files thead th {
  68. text-align: left;
  69. background-position: 3px center !important;
  70. }`
  71. ]
  72. }
  73. },
  74. // github.com (all activity list)
  75. "all-activity": {
  76. check: el => $("#dashboard") &&
  77. el.classList.contains("js-all-activity-header"),
  78. sort: el => {
  79. const list = $$("div[data-repository-hovercards-enabled]:not(.js-details-container) > div");
  80. const wrap = list.parentElement;
  81. initSortList(
  82. el,
  83. list,
  84. { selector: "relative-time", attr: "datetime" }
  85. );
  86. // Move "More" button to bottom
  87. setTimeout(() => {
  88. movePaginate(wrap);
  89. });
  90. },
  91. css: {
  92. unsorted: [
  93. ".js-all-activity-header"
  94. ],
  95. extras: [
  96. "div[data-repository-hovercards-enabled] div:empty { display: none; }"
  97. ]
  98. }
  99. },
  100. // github.com (recent activity list)
  101. "recent-activity": {
  102. check: el => $("#dashboard") &&
  103. el.matches(".news > h2:not(.js-all-activity-header)"),
  104. sort: el => {
  105. initSortList(
  106. el,
  107. $$(".js-recent-activity-container ul li"),
  108. { selector: "relative-time", attr: "datetime" }
  109. );
  110. // Not sure why, but sorting shows all recent activity; so, hide the
  111. // "Show more" button
  112. $(".js-show-more-recent-items").classList.add("d-none");
  113. },
  114. css: {
  115. unsorted: [
  116. ".news h2:not(.js-all-activity-header)"
  117. ]
  118. }
  119. },
  120. // github.com (sidebar repos & teams)
  121. "sidebar": {
  122. check: el => $(".dashboard-sidebar") &&
  123. el.matches(".dashboard-sidebar h2"),
  124. sort: el => initSortList(
  125. el,
  126. $$(".list-style-none li", el.closest(".js-repos-container")),
  127. { selector: "a" }
  128. ),
  129. css: {
  130. unsorted: [
  131. ".dashboard-sidebar h2"
  132. ],
  133. tweaks: [
  134. `.dashboard-sidebar h2.pt-3 {
  135. background-position: left bottom !important;
  136. }`
  137. ]
  138. }
  139. },
  140. // github.com/(:user|:org) (pinned repos)
  141. "pinned": {
  142. check: el => el.matches(".js-pinned-items-reorder-container h2"),
  143. sort: el => initSortList(
  144. el,
  145. // org li, own repos li
  146. $$(".js-pinned-items-reorder-list li, #choose-pinned-repositories ~ ol li"),
  147. { selector: "a.text-bold" }
  148. ),
  149. css: {
  150. unsorted: [
  151. ".js-pinned-items-reorder-container h2"
  152. ],
  153. // tweaks: [
  154. // `.js-pinned-items-reorder-container h2 {
  155. // padding-left: 22px;
  156. // background-position: left center !important;
  157. // }`
  158. // ]
  159. }
  160. },
  161. // github.com/:org
  162. "org-repos": {
  163. setup: () => {
  164. const form = $("form[data-results-container='org-repositories']");
  165. if (form) {
  166. form.parentElement.classList.add("ghsc-org-repos-header");
  167. }
  168. },
  169. check: el => el.matches(".ghsc-org-repos-header"),
  170. sort: el => initSortList(
  171. el,
  172. $$(".org-repos li"),
  173. { selector: "a[itemprop*='name']" }
  174. ),
  175. css: {
  176. unsorted: [
  177. ".ghsc-org-repos-header"
  178. ],
  179. tweaks: [
  180. `form[data-results-container='org-repositories'] {
  181. cursor: default;
  182. }`
  183. ]
  184. }
  185. },
  186. // github.com/orgs/:org/people
  187. // github.com/orgs/:org/outside-collaborators
  188. // github.com/orgs/:org/teams
  189. // github.com/orgs/:org/teams/:team/teams
  190. // github.com/orgs/:org/teams/:team/repositories
  191. "org-people+teams": {
  192. check: el => el.matches(".org-toolbar"),
  193. sort: el => {
  194. const lists = [
  195. "#org-members-table li",
  196. "#org-outside-collaborators li",
  197. "#org-teams li", // for :org/teams & :org/teams/:team/teams
  198. "#org-team-repositories li"
  199. ].join(",");
  200. // Using a[id] returns a (possibly) truncated full name instead of
  201. // the GitHub handle
  202. initSortList(el, $$(lists), { selector: "a[id], a.f4" });
  203. },
  204. css: {
  205. unsorted: [
  206. ".org-toolbar"
  207. ]
  208. }
  209. },
  210. // github.com/orgs/:org/teams/:team/members
  211. "team-members": {
  212. // no ".org-toolbar" on this page :(
  213. setup: () => {
  214. const form = $("form[data-results-container='team-members']");
  215. if (form) {
  216. form.parentElement.classList.add("ghsc-team-members-header");
  217. }
  218. },
  219. check: el => el.matches(".ghsc-team-members-header"),
  220. sort: el => initSortList(el, $$("#team-members li")),
  221. css: {
  222. unsorted: [
  223. ".ghsc-team-members-header"
  224. ]
  225. }
  226. },
  227. // github.com/orgs/:org/projects
  228. "org-projects": {
  229. setup: () => {
  230. const form = $("form[action$='/projects']");
  231. if (form) {
  232. form.parentElement.classList.add("ghsc-project-header");
  233. }
  234. },
  235. check: el => el.matches(".ghsc-project-header"),
  236. sort: el => initSortList(
  237. el,
  238. $$("#projects-results > div"),
  239. { selector: "h4 a" }
  240. ),
  241. css: {
  242. unsorted: [
  243. ".ghsc-project-header"
  244. ]
  245. }
  246. },
  247. // github.com/:user?tab=repositories
  248. "user-repos": {
  249. setup: () => {
  250. const form = $("form[data-results-container='user-repositories-list']");
  251. if (form) {
  252. form.parentElement.classList.add("ghsc-repos-header");
  253. }
  254. },
  255. check: el => el.matches(".ghsc-repos-header"),
  256. sort: el => initSortList(
  257. el,
  258. $$("#user-repositories-list li"),
  259. { selector: "a[itemprop*='name']" }
  260. ),
  261. css: {
  262. unsorted: [
  263. ".ghsc-repos-header"
  264. ],
  265. tweaks: [
  266. `form[data-results-container='user-repositories-list'] {
  267. cursor: default;
  268. }`
  269. ]
  270. }
  271. },
  272. // github.com/:user?tab=stars
  273. "user-stars": {
  274. setup: () => {
  275. const form = $("form[action$='?tab=stars']");
  276. if (form) {
  277. // filter form is wrapped in a details/summary
  278. const details = form.closest("details");
  279. if (details) {
  280. details.parentElement.classList.add("ghsc-stars-header");
  281. details.parentElement.title = "Sort list by repo name";
  282. }
  283. }
  284. },
  285. check: el => el.matches(".ghsc-stars-header"),
  286. sort: el => {
  287. const wrap = el.parentElement;
  288. const list = $$(".d-block", wrap);
  289. list.forEach(elm => {
  290. const a = $("h3 a", elm);
  291. a.dataset.text = a.textContent.split("/")[1];
  292. });
  293. initSortList(el, list, { selector: "h3 a", attr: "data-text" });
  294. movePaginate(wrap);
  295. },
  296. css: {
  297. unsorted: [
  298. ".ghsc-stars-header"
  299. ],
  300. tweaks: [
  301. `.ghsc-stars-header {
  302. background-position: left top !important;
  303. }`
  304. ]
  305. }
  306. },
  307. // github.com/:user?tab=follow(ers|ing)
  308. "user-tab-follow": {
  309. setup: () => {
  310. const tab = $("a[href*='?tab=follow'].selected");
  311. if (tab) {
  312. tab.parentElement.parentElement.classList.add("ghsc-follow-nav");
  313. }
  314. },
  315. check: (el, loc) => loc.search.indexOf("tab=follow") > -1 &&
  316. el.matches(".ghsc-follow-nav"),
  317. sort: el => {
  318. const wrap = el.parentElement;
  319. initSortList(
  320. el,
  321. $$(".position-relative .d-table", wrap),
  322. { selector: ".col-9 a" }
  323. );
  324. movePaginate(wrap);
  325. },
  326. css: {
  327. unsorted: [
  328. "div.ghsc-follow-nav"
  329. ]
  330. }
  331. },
  332. // github.com/:user/follow(ers|ing)
  333. // github.com/:user/follow(ers|ing)/you_know
  334. "user-follow": {
  335. setup: loc => {
  336. if (loc.href.indexOf("/follow") > -1) {
  337. const list = $(".follow-list");
  338. const wrap = list && list.closest(".container");
  339. if (wrap) {
  340. $("h2", wrap).classList.add("ghsc-follow-header");
  341. }
  342. }
  343. },
  344. check: el => el.matches(".ghsc-follow-header"),
  345. sort: el => initSortList(
  346. el,
  347. $$(".follow-list li"),
  348. { selector: ".follow-list-name span", attr: "title" }
  349. ),
  350. css: {
  351. unsorted: [
  352. ".ghsc-follow-header"
  353. ]
  354. }
  355. },
  356. // github.com/watching (watching table only)
  357. "user-watch": {
  358. setup: loc => {
  359. if (loc.href.indexOf("/watching") > -1) {
  360. const header = $(".tabnav");
  361. header.classList.add("ghsc-watching-header");
  362. header.title = "Sort list by repo name";
  363. }
  364. },
  365. check: el => el.matches(".ghsc-watching-header"),
  366. sort: el => {
  367. const list = $$(".standalone.repo-list li");
  368. list.forEach(elm => {
  369. const link = $("a", elm);
  370. link.dataset.sort = link.title.split("/")[1];
  371. });
  372. initSortList(el, list, { selector: "a", attr: "data-sort" });
  373. },
  374. css: {
  375. unsorted: [
  376. ".ghsc-watching-header"
  377. ]
  378. }
  379. },
  380. // github.com/(:user|:org)/:repo/(stargazers|watchers)
  381. "repo-stars-or-watchers": {
  382. setup: loc => {
  383. if (
  384. loc.href.indexOf("/stargazers") > -1 ||
  385. loc.href.indexOf("/watchers") > -1
  386. ) {
  387. $("#repos > h2").classList.add("ghsc-gazer-header");
  388. }
  389. },
  390. check: el => el.matches(".ghsc-gazer-header"),
  391. sort: el => initSortList(
  392. el,
  393. $$(".follow-list-item"),
  394. { selector: ".follow-list-name" }
  395. ),
  396. css: {
  397. unsorted: [
  398. ".ghsc-gazer-header"
  399. ]
  400. }
  401. }
  402. };
  403.  
  404. const sorts = ["asc", "desc"];
  405.  
  406. const icons = {
  407. unsorted: color => `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="${color}">
  408. <path d="M15 8H1l7-8zm0 1H1l7 7z" opacity=".2"/>
  409. </svg>`,
  410. asc: color => `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="${color}">
  411. <path d="M15 8H1l7-8z"/>
  412. <path d="M15 9H1l7 7z" opacity=".2"/>
  413. </svg>`,
  414. desc: color => `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="${color}">
  415. <path d="M15 8H1l7-8z" opacity=".2"/>
  416. <path d="M15 9H1l7 7z"/>
  417. </svg>`
  418. };
  419.  
  420. function getIcon(type, color) {
  421. return "data:image/svg+xml;charset=UTF-8," +
  422. encodeURIComponent(icons[type](color));
  423. }
  424.  
  425. function needDarkTheme() {
  426. // color will be "rgb(#, #, #)" or "rgba(#, #, #, #)"
  427. let color = window.getComputedStyle(document.body).backgroundColor;
  428. const rgb = (color || "")
  429. .replace(/\s/g, "")
  430. .match(/^rgba?\((\d+),(\d+),(\d+)/i);
  431. if (rgb) {
  432. // remove "rgb.." part from match & parse
  433. const colors = rgb.slice(1).map(Number);
  434. // http://stackoverflow.com/a/15794784/145346
  435. const brightest = Math.max(...colors);
  436. // return true if we have a dark background
  437. return brightest < 128;
  438. }
  439. // fallback to bright background
  440. return false;
  441. }
  442.  
  443. function addRepoFileThead() {
  444. const $table = $("table.files");
  445. if ($table && !$(".ghsc-header", $table)) {
  446. const thead = document.createElement("thead");
  447. thead.innerHTML = `<tr class="ghsc-header">
  448. <td></td>
  449. <th>Content</th>
  450. <th>Message</th>
  451. <th class="ghsc-age">Age</th>
  452. </tr>`;
  453. $table.prepend(thead);
  454. }
  455. }
  456.  
  457. function initSortTable(el) {
  458. removeSelection();
  459. const dir = el.classList.contains(sorts[0]) ? sorts[1] : sorts[0],
  460. table = el.closest("table"),
  461. options = {
  462. order: dir,
  463. natural: true,
  464. selector: `td:nth-child(${el.cellIndex + 1})`
  465. };
  466. if (el.classList.contains("ghsc-age")) {
  467. // sort repo age column using ISO 8601 datetime format
  468. options.selector += " [datetime]";
  469. options.attr = "datetime";
  470. }
  471. tinysort($$("tbody tr:not(.up-tree)", table), options);
  472. $$("th", table).forEach(elm => {
  473. elm.classList.remove(...sorts);
  474. });
  475. el.classList.add(dir);
  476. }
  477.  
  478. function initSortList(header, list, opts = {}) {
  479. if (list) {
  480. removeSelection();
  481. const dir = header.classList.contains(sorts[0]) ? sorts[1] : sorts[0];
  482. const options = {
  483. order: dir,
  484. natural: true,
  485. place: "first", // Fixes nested ajax of main feed
  486. ...opts
  487. };
  488. tinysort(list, options);
  489. header.classList.remove(...sorts);
  490. header.classList.add(dir);
  491. }
  492. }
  493.  
  494. function getCss(type) {
  495. return Object.keys(sortables).reduce((acc, block) => {
  496. const css = sortables[block].css || {};
  497. const selectors = css[type];
  498. if (selectors) {
  499. acc.push(...selectors);
  500. } else if (type !== "unsorted" && type !== "tweaks") {
  501. const useUnsorted = css.unsorted || [];
  502. if (useUnsorted.length) {
  503. // if "asc" or "desc" isn't defined, then append that class to the
  504. // unsorted value
  505. acc.push(`${useUnsorted.join(`.${type},`)}.${type}`);
  506. }
  507. }
  508. return acc;
  509. }, []).join(type === "tweaks" ? "" : ",");
  510. }
  511.  
  512. // The paginate block is a sibling along with the items in the list...
  513. // it needs to be moved to the end
  514. function movePaginate(wrapper) {
  515. const pager = wrapper &&
  516. $(".paginate-container, .ajax-pagination-form", wrapper);
  517. if (pager) {
  518. wrapper.append(pager);
  519. }
  520. }
  521.  
  522. function $(str, el) {
  523. return (el || document).querySelector(str);
  524. }
  525.  
  526. function $$(str, el) {
  527. return [...(el || document).querySelectorAll(str)];
  528. }
  529.  
  530. function removeSelection() {
  531. // remove text selection - http://stackoverflow.com/a/3171348/145346
  532. const sel = window.getSelection ?
  533. window.getSelection() :
  534. document.selection;
  535. if (sel) {
  536. if (sel.removeAllRanges) {
  537. sel.removeAllRanges();
  538. } else if (sel.empty) {
  539. sel.empty();
  540. }
  541. }
  542. }
  543.  
  544. function update() {
  545. Object.keys(sortables).forEach(item => {
  546. if (sortables[item].setup) {
  547. sortables[item].setup(window.location);
  548. }
  549. });
  550. }
  551.  
  552. function init() {
  553. const color = needDarkTheme() ? "#ddd" : "#222";
  554.  
  555. GM.addStyle(`
  556. /* Added table header */
  557. tr.ghsc-header th, tr.ghsc-header td {
  558. border-bottom: #eee 1px solid;
  559. padding: 2px 2px 2px 10px;
  560. }
  561. /* sort icons */
  562. ${getCss("unsorted")} {
  563. cursor: pointer;
  564. padding-left: 22px !important;
  565. background-image: url(${getIcon("unsorted", color)}) !important;
  566. background-repeat: no-repeat !important;
  567. background-position: left center !important;
  568. }
  569. ${getCss("asc")} {
  570. background-image: url(${getIcon("asc", color)}) !important;
  571. background-repeat: no-repeat !important;
  572. }
  573. ${getCss("desc")} {
  574. background-image: url(${getIcon("desc", color)}) !important;
  575. background-repeat: no-repeat !important;
  576. }
  577. /* specific tweaks */
  578. ${getCss("tweaks")}`
  579. );
  580.  
  581. document.body.addEventListener("click", event => {
  582. const target = event.target;
  583. if (target && target.nodeType === 1) {
  584. Object.keys(sortables).some(item => {
  585. const el = sortables[item].check(target, window.location);
  586. if (el) {
  587. sortables[item].sort(el instanceof HTMLElement ? el : target);
  588. event.preventDefault();
  589. return true;
  590. }
  591. return false;
  592. });
  593. }
  594. });
  595. update();
  596. }
  597.  
  598. document.addEventListener("ghmo:container", () => update());
  599. init();
  600. })();