GitHub Sort Content

A userscript that makes some lists & markdown tables sortable

目前为 2022-10-24 提交的版本,查看 最新版本

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