GitHub Sort Content

A userscript that makes some lists & markdown tables sortable

当前为 2019-09-02 提交的版本,查看 最新版本

  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://greasyfork.org/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. })();