GitHub Repo Age

Displays repository creation date/time/age.

  1. // ==UserScript==
  2. // @name GitHub Repo Age
  3. // @description Displays repository creation date/time/age.
  4. // @icon https://github.githubassets.com/favicons/favicon-dark.svg
  5. // @version 1.5
  6. // @author afkarxyz
  7. // @namespace https://github.com/afkarxyz/userscripts/
  8. // @supportURL https://github.com/afkarxyz/userscripts/issues
  9. // @license MIT
  10. // @match https://github.com/*/*
  11. // @grant GM_xmlhttpRequest
  12. // @connect api.codetabs.com
  13. // @connect api.cors.lol
  14. // @connect api.allorigins.win
  15. // @connect everyorigin.jwvbremen.nl
  16. // @connect api.github.com
  17. // @require https://cdn.jsdelivr.net/npm/date-fns@4.1.0/cdn.min.js
  18. // ==/UserScript==
  19.  
  20. ;(() => {
  21. const CACHE_KEY_PREFIX = "github_repo_created_"
  22.  
  23. const proxyServices = [
  24. {
  25. name: "Direct GitHub API",
  26. url: "https://api.github.com/repos/",
  27. parseResponse: (response) => {
  28. return JSON.parse(response)
  29. },
  30. },
  31. {
  32. name: "CodeTabs Proxy",
  33. url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/repos/",
  34. parseResponse: (response) => {
  35. return JSON.parse(response)
  36. },
  37. },
  38. {
  39. name: "CORS.lol Proxy",
  40. url: "https://api.cors.lol/?url=https://api.github.com/repos/",
  41. parseResponse: (response) => {
  42. return JSON.parse(response)
  43. },
  44. },
  45. {
  46. name: "AllOrigins Proxy",
  47. url: "https://api.allorigins.win/get?url=https://api.github.com/repos/",
  48. parseResponse: (response) => {
  49. const parsed = JSON.parse(response)
  50. return JSON.parse(parsed.contents)
  51. },
  52. },
  53. {
  54. name: "EveryOrigin Proxy",
  55. url: "https://everyorigin.jwvbremen.nl/api/get?url=https://api.github.com/repos/",
  56. parseResponse: (response) => {
  57. const parsed = JSON.parse(response)
  58. return JSON.parse(parsed.html)
  59. },
  60. },
  61. ]
  62.  
  63. const selectors = {
  64. desktop: [".BorderGrid-cell .hide-sm.hide-md .f4.my-3", ".BorderGrid-cell"],
  65. mobile: [
  66. ".d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .f4.mb-3.color-fg-muted",
  67. ".d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .d-flex.gap-2.mt-n3.mb-3.flex-wrap",
  68. ".d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5",
  69. ],
  70. }
  71.  
  72. let currentRepoPath = ""
  73.  
  74. function formatDate(isoDateStr) {
  75. const createdDate = new Date(isoDateStr)
  76. const now = new Date()
  77. const datePart = dateFns.format(createdDate, "dd MMM yyyy")
  78. const timePart = dateFns.format(createdDate, "HH:mm")
  79. const diffYears = dateFns.differenceInYears(now, createdDate)
  80. const tempDate = dateFns.addYears(createdDate, diffYears)
  81. const diffMonths = dateFns.differenceInMonths(now, tempDate)
  82. const tempDate2 = dateFns.addMonths(tempDate, diffMonths)
  83. const diffDays = dateFns.differenceInDays(now, tempDate2)
  84. const tempDate3 = dateFns.addDays(tempDate2, diffDays)
  85. const diffHours = dateFns.differenceInHours(now, tempDate3)
  86. const tempDate4 = dateFns.addHours(tempDate3, diffHours)
  87. const diffMinutes = dateFns.differenceInMinutes(now, tempDate4)
  88. let ageText = ""
  89. if (diffYears > 0) {
  90. ageText = `${diffYears} year${diffYears !== 1 ? "s" : ""}`
  91. if (diffMonths > 0) {
  92. ageText += ` ${diffMonths} month${diffMonths !== 1 ? "s" : ""}`
  93. }
  94. } else if (dateFns.differenceInMonths(now, createdDate) > 0) {
  95. const totalMonths = dateFns.differenceInMonths(now, createdDate)
  96. ageText = `${totalMonths} month${totalMonths !== 1 ? "s" : ""}`
  97. if (diffDays > 0) {
  98. ageText += ` ${diffDays} day${diffDays !== 1 ? "s" : ""}`
  99. }
  100. } else if (dateFns.differenceInDays(now, createdDate) > 0) {
  101. const totalDays = dateFns.differenceInDays(now, createdDate)
  102. ageText = `${totalDays} day${totalDays !== 1 ? "s" : ""}`
  103. if (diffHours > 0 && totalDays < 7) {
  104. ageText += ` ${diffHours} hour${diffHours !== 1 ? "s" : ""}`
  105. }
  106. } else if (dateFns.differenceInHours(now, createdDate) > 0) {
  107. const totalHours = dateFns.differenceInHours(now, createdDate)
  108. ageText = `${totalHours} hour${totalHours !== 1 ? "s" : ""}`
  109. if (diffMinutes > 0) {
  110. ageText += ` ${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""}`
  111. }
  112. } else {
  113. const totalMinutes = dateFns.differenceInMinutes(now, createdDate)
  114. ageText = `${totalMinutes} minute${totalMinutes !== 1 ? "s" : ""}`
  115. }
  116.  
  117. return `${datePart} - ${timePart} (${ageText} ago)`
  118. }
  119.  
  120. const cache = {
  121. getKey: (user, repo) => `${CACHE_KEY_PREFIX}${user}_${repo}`,
  122.  
  123. get: function (user, repo) {
  124. try {
  125. const key = this.getKey(user, repo)
  126. const cachedValue = localStorage.getItem(key)
  127. if (!cachedValue) return null
  128. return cachedValue
  129. } catch (err) {
  130. return null
  131. }
  132. },
  133.  
  134. set: function (user, repo, value) {
  135. try {
  136. const key = this.getKey(user, repo)
  137. localStorage.setItem(key, value)
  138. } catch (err) {
  139.  
  140. }
  141. },
  142. }
  143.  
  144. async function fetchFromApi(proxyService, user, repo) {
  145. const apiUrl = `${proxyService.url}${user}/${repo}`
  146.  
  147. return new Promise((resolve) => {
  148. if (typeof GM_xmlhttpRequest === "undefined") {
  149. resolve({ success: false, error: "GM_xmlhttpRequest is not defined" })
  150. return
  151. }
  152.  
  153. GM_xmlhttpRequest({
  154. method: "GET",
  155. url: apiUrl,
  156. headers: {
  157. Accept: "application/vnd.github.v3+json",
  158. },
  159. onload: (response) => {
  160. if (response.responseText.includes("limit") && response.responseText.includes("API")) {
  161. resolve({
  162. success: false,
  163. error: "Rate limit exceeded",
  164. isRateLimit: true,
  165. })
  166. return
  167. }
  168.  
  169. if (response.status >= 200 && response.status < 300) {
  170. try {
  171. const data = proxyService.parseResponse(response.responseText)
  172. const createdAt = data.created_at
  173. if (createdAt) {
  174. resolve({ success: true, data: createdAt })
  175. } else {
  176. resolve({ success: false, error: "Missing creation date" })
  177. }
  178. } catch (e) {
  179. resolve({ success: false, error: "JSON parse error" })
  180. }
  181. } else {
  182. resolve({
  183. success: false,
  184. error: `Status ${response.status}`,
  185. })
  186. }
  187. },
  188. onerror: () => {
  189. resolve({ success: false, error: "Network error" })
  190. },
  191. ontimeout: () => {
  192. resolve({ success: false, error: "Timeout" })
  193. },
  194. })
  195. })
  196. }
  197.  
  198. async function getRepoCreationDate(user, repo) {
  199. const cachedDate = cache.get(user, repo)
  200. if (cachedDate) {
  201. return cachedDate
  202. }
  203.  
  204. for (let i = 0; i < proxyServices.length; i++) {
  205. const proxyService = proxyServices[i]
  206. const result = await fetchFromApi(proxyService, user, repo)
  207.  
  208. if (result.success) {
  209. cache.set(user, repo, result.data)
  210. return result.data
  211. }
  212. }
  213.  
  214. return null
  215. }
  216.  
  217. async function insertCreatedDate() {
  218. const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/)
  219. if (!match) return false
  220.  
  221. const [_, user, repo] = match
  222. const repoPath = `${user}/${repo}`
  223.  
  224. currentRepoPath = repoPath
  225.  
  226. const createdAt = await getRepoCreationDate(user, repo)
  227. if (!createdAt) return false
  228.  
  229. const formattedDate = formatDate(createdAt)
  230. let insertedCount = 0
  231.  
  232. document.querySelectorAll(".repo-created-date").forEach((el) => el.remove())
  233.  
  234. for (const [view, selectorsList] of Object.entries(selectors)) {
  235. for (const selector of selectorsList) {
  236. const element = document.querySelector(selector)
  237. if (element && !element.querySelector(`.repo-created-${view}`)) {
  238. insertDateElement(element, formattedDate, view, createdAt)
  239. insertedCount++
  240. break
  241. }
  242. }
  243. }
  244.  
  245. return insertedCount > 0
  246. }
  247.  
  248. function insertDateElement(targetElement, formattedDate, view, isoDateStr) {
  249. const p = document.createElement("p")
  250. p.className = `f6 color-fg-muted repo-created-date repo-created-${view}`
  251. p.dataset.createdAt = isoDateStr
  252. p.style.marginTop = "4px"
  253. p.style.marginBottom = "8px"
  254. p.innerHTML = `<strong>Created</strong> ${formattedDate}`
  255.  
  256. if (view === "mobile") {
  257. const flexWrap = targetElement.querySelector(".flex-wrap")
  258. if (flexWrap) {
  259. flexWrap.parentNode.insertBefore(p, flexWrap.nextSibling)
  260. return
  261. }
  262.  
  263. const dFlex = targetElement.querySelector(".d-flex")
  264. if (dFlex) {
  265. dFlex.parentNode.insertBefore(p, dFlex.nextSibling)
  266. return
  267. }
  268. }
  269.  
  270. targetElement.insertBefore(p, targetElement.firstChild)
  271. }
  272.  
  273. function updateAges() {
  274. document.querySelectorAll(".repo-created-date").forEach((el) => {
  275. const createdAt = el.dataset.createdAt
  276. if (createdAt) {
  277. const formattedDate = formatDate(createdAt)
  278. const strongElement = el.querySelector("strong")
  279. if (strongElement) {
  280. el.innerHTML = `<strong>Created</strong> ${formattedDate}`
  281. } else {
  282. el.innerHTML = formattedDate
  283. }
  284. }
  285. })
  286. }
  287.  
  288. function checkAndInsertWithRetry(retryCount = 0, maxRetries = 5) {
  289. insertCreatedDate().then((inserted) => {
  290. if (!inserted && retryCount < maxRetries) {
  291. const delay = Math.pow(2, retryCount) * 500
  292. setTimeout(() => checkAndInsertWithRetry(retryCount + 1, maxRetries), delay)
  293. }
  294. })
  295. }
  296.  
  297. function checkForRepoChange() {
  298. const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/)
  299. if (!match) return
  300.  
  301. const [_, user, repo] = match
  302. const repoPath = `${user}/${repo}`
  303.  
  304. if (repoPath !== currentRepoPath) {
  305. checkAndInsertWithRetry()
  306. }
  307. }
  308.  
  309. if (document.readyState === "loading") {
  310. document.addEventListener("DOMContentLoaded", () => checkAndInsertWithRetry())
  311. } else {
  312. checkAndInsertWithRetry()
  313. }
  314.  
  315. const originalPushState = history.pushState
  316. history.pushState = function () {
  317. originalPushState.apply(this, arguments)
  318. setTimeout(checkForRepoChange, 100)
  319. }
  320.  
  321. const originalReplaceState = history.replaceState
  322. history.replaceState = function () {
  323. originalReplaceState.apply(this, arguments)
  324. setTimeout(checkForRepoChange, 100)
  325. }
  326.  
  327. window.addEventListener("popstate", () => {
  328. setTimeout(checkForRepoChange, 100)
  329. })
  330.  
  331. const observer = new MutationObserver((mutations) => {
  332. for (const mutation of mutations) {
  333. if (
  334. mutation.type === "childList" &&
  335. (mutation.target.id === "js-repo-pjax-container" || mutation.target.id === "repository-container-header")
  336. ) {
  337. setTimeout(checkForRepoChange, 100)
  338. break
  339. }
  340. }
  341. })
  342.  
  343. observer.observe(document.body, { childList: true, subtree: true })
  344. setInterval(updateAges, 60000)
  345. })()