GitHub Gist Copier

Add copy button to Gist files for easy code copying.

  1. // ==UserScript==
  2. // @name GitHub Gist Copier
  3. // @description Add copy button to Gist files for easy code copying.
  4. // @icon https://github.githubassets.com/favicons/favicon-dark.svg
  5. // @version 1.3
  6. // @author afkarxyz
  7. // @namespace https://github.com/afkarxyz/userscripts/
  8. // @supportURL https://github.com/afkarxyz/userscripts/issues
  9. // @license MIT
  10. // @run-at document-end
  11. // @match https://gist.github.com/*
  12. // @grant GM_xmlhttpRequest
  13. // @grant GM_setClipboard
  14. // @grant GM_addStyle
  15. // @connect api.codetabs.com
  16. // @connect api.cors.lol
  17. // @connect api.allorigins.win
  18. // @connect everyorigin.jwvbremen.nl
  19. // @connect gist.githubusercontent.com
  20. // ==/UserScript==
  21.  
  22. ;(() => {
  23. let isRequestInProgress = false
  24. GM_addStyle(`
  25. @keyframes spin {
  26. 0% { transform: rotate(0deg); }
  27. 100% { transform: rotate(360deg); }
  28. }
  29. .gist-copy-spinner {
  30. animation: spin 0.75s linear infinite;
  31. transform-origin: center;
  32. }
  33. `)
  34. const proxyServices = [
  35. {
  36. name: "CodeTabs Proxy",
  37. url: "https://api.codetabs.com/v1/proxy/?quest=",
  38. },
  39. {
  40. name: "CORS.lol Proxy",
  41. url: "https://api.cors.lol/?url=",
  42. },
  43. {
  44. name: "AllOrigins Proxy",
  45. url: "https://api.allorigins.win/get?url=",
  46. parseResponse: (response) => {
  47. const parsed = JSON.parse(response)
  48. return parsed.contents
  49. },
  50. },
  51. {
  52. name: "EveryOrigin Proxy",
  53. url: "https://everyorigin.jwvbremen.nl/api/get?url=",
  54. parseResponse: (response) => {
  55. const parsed = JSON.parse(response)
  56. return parsed.html
  57. },
  58. },
  59. ]
  60. function noop() {}
  61. function debounce(f, delay) {
  62. let timeoutId = null
  63. return function (...args) {
  64. if (timeoutId) {
  65. clearTimeout(timeoutId)
  66. }
  67. timeoutId = setTimeout(() => {
  68. f.apply(this, args)
  69. }, delay)
  70. }
  71. }
  72. async function fetchWithProxy(rawUrl, proxyIndex = 0) {
  73. if (proxyIndex >= proxyServices.length) {
  74. throw new Error("All proxies failed")
  75. }
  76. const proxyService = proxyServices[proxyIndex]
  77. const proxiedUrl = `${proxyService.url}${encodeURIComponent(rawUrl)}`
  78. return new Promise((resolve, reject) => {
  79. GM_xmlhttpRequest({
  80. method: "GET",
  81. url: proxiedUrl,
  82. headers: {
  83. Accept: "text/plain, application/json, */*",
  84. },
  85. followRedirect: true,
  86. onload: (response) => {
  87. if (response.responseText.includes("limit") && response.responseText.includes("API")) {
  88. fetchWithProxy(rawUrl, proxyIndex + 1)
  89. .then(resolve)
  90. .catch(reject)
  91. return
  92. }
  93. if (response.status === 200) {
  94. try {
  95. let responseText = response.responseText
  96. if (proxyService.parseResponse) {
  97. responseText = proxyService.parseResponse(responseText)
  98. }
  99. if (responseText.includes("<a href=") && responseText.includes("Moved Permanently")) {
  100. const match = responseText.match(/href="([^"]+)"/)
  101. if (match && match[1]) {
  102. const redirectUrl = match[1].startsWith("/") ? `${new URL(proxiedUrl).origin}${match[1]}` : match[1]
  103. GM_xmlhttpRequest({
  104. method: "GET",
  105. url: redirectUrl,
  106. headers: {
  107. Accept: "text/plain, application/json, */*",
  108. },
  109. onload: (redirectResponse) => {
  110. if (redirectResponse.status === 200) {
  111. resolve(redirectResponse.responseText)
  112. } else {
  113. fetchWithProxy(rawUrl, proxyIndex + 1)
  114. .then(resolve)
  115. .catch(reject)
  116. }
  117. },
  118. onerror: () => {
  119. fetchWithProxy(rawUrl, proxyIndex + 1)
  120. .then(resolve)
  121. .catch(reject)
  122. },
  123. })
  124. } else {
  125. fetchWithProxy(rawUrl, proxyIndex + 1)
  126. .then(resolve)
  127. .catch(reject)
  128. }
  129. } else {
  130. resolve(responseText)
  131. }
  132. } catch (e) {
  133. fetchWithProxy(rawUrl, proxyIndex + 1)
  134. .then(resolve)
  135. .catch(reject)
  136. }
  137. } else {
  138. fetchWithProxy(rawUrl, proxyIndex + 1)
  139. .then(resolve)
  140. .catch(reject)
  141. }
  142. },
  143. onerror: () => {
  144. fetchWithProxy(rawUrl, proxyIndex + 1)
  145. .then(resolve)
  146. .catch(reject)
  147. },
  148. })
  149. })
  150. }
  151. function createCopyButton(fileElement) {
  152. const fileActionElement = fileElement.querySelector(".file-actions")
  153. if (!fileActionElement) {
  154. return noop
  155. }
  156. const rawButton = fileActionElement.querySelector('a[href*="/raw/"]')
  157. if (!rawButton) {
  158. return noop
  159. }
  160. const button = document.createElement("button")
  161. button.className = "btn-octicon gist-copy-button"
  162. button.style.marginRight = "5px"
  163. button.innerHTML = `
  164. <svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-copy">
  165. <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path>
  166. </svg>
  167. <svg style="display: none;" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" class="gist-spinner">
  168. <path fill="currentColor" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity="0.25"/>
  169. <path fill="currentColor" d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z" class="gist-copy-spinner"/>
  170. </svg>
  171. <svg style="display: none;" aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-check color-fg-success">
  172. <path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path>
  173. </svg>
  174. `
  175. const copyIcon = button.querySelector(".octicon-copy")
  176. const spinnerIcon = button.querySelector(".gist-spinner")
  177. const checkIcon = button.querySelector(".octicon-check")
  178. let timeoutId = null
  179. const copyHandler = async (e) => {
  180. if (timeoutId || isRequestInProgress) {
  181. return
  182. }
  183. e.preventDefault()
  184. isRequestInProgress = true
  185. copyIcon.style.display = "none"
  186. spinnerIcon.style.display = "inline-block"
  187. try {
  188. const rawUrl = rawButton.href
  189. const content = await fetchWithProxy(rawUrl)
  190. GM_setClipboard(content, { type: "text", mimetype: "text/plain" })
  191. spinnerIcon.style.display = "none"
  192. checkIcon.style.display = "inline-block"
  193. timeoutId = setTimeout(() => {
  194. checkIcon.style.display = "none"
  195. copyIcon.style.display = "inline-block"
  196. timeoutId = null
  197. }, 500)
  198. } catch (error) {
  199. spinnerIcon.style.display = "none"
  200. copyIcon.style.display = "inline-block"
  201. } finally {
  202. isRequestInProgress = false
  203. }
  204. }
  205. button.addEventListener("click", copyHandler)
  206. fileActionElement.insertBefore(button, fileActionElement.firstChild)
  207. return () => {
  208. button.removeEventListener("click", copyHandler)
  209. if (timeoutId) {
  210. clearTimeout(timeoutId)
  211. }
  212. button.remove()
  213. }
  214. }
  215. function runGistCopy() {
  216. let removeAllListeners = noop
  217. function tryCreateCopyButtons() {
  218. removeAllListeners()
  219. const fileElements = [...document.querySelectorAll(".file")]
  220. const removeListeners = fileElements.map(createCopyButton)
  221. removeAllListeners = () => {
  222. removeListeners.map((f) => f())
  223. ;[...document.querySelectorAll(".gist-copy-button")].forEach((el) => {
  224. el.remove()
  225. })
  226. }
  227. }
  228. setTimeout(tryCreateCopyButtons, 300)
  229. const observer = new MutationObserver(
  230. debounce(() => {
  231. if (
  232. document.querySelectorAll(".file").length > 0 &&
  233. document.querySelectorAll(".gist-copy-button").length === 0
  234. ) {
  235. tryCreateCopyButtons()
  236. }
  237. }, 100),
  238. )
  239. observer.observe(document.body, {
  240. childList: true,
  241. subtree: true,
  242. })
  243. if (window.onurlchange === null) {
  244. window.addEventListener("urlchange", debounce(tryCreateCopyButtons, 16))
  245. }
  246. }
  247. runGistCopy()
  248. })()