GitHub Sort Content

A userscript that makes some lists & markdown tables sortable

目前为 2020-09-08 提交的版本。查看 最新版本

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