Github 时间格式转换

将 GitHub 页面上的相对时间转换为绝对日期和时间

当前为 2025-04-25 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Github Time Format Converter
  3. // @name:zh-CN Github 时间格式转换
  4. // @name:zh-TW Github 時間格式轉換
  5. // @description Convert relative times on GitHub to absolute date and time
  6. // @description:zh-CN 将 GitHub 页面上的相对时间转换为绝对日期和时间
  7. // @description:zh-TW 將 GitHub 頁面上的相對時間轉換成絕對日期與時間
  8. // @version 1.0.1
  9. // @icon https://raw.githubusercontent.com/MiPoNianYou/UserScripts/refs/heads/main/Icons/GithubTimeFormatConverterIcon.svg
  10. // @author 念柚
  11. // @namespace https://github.com/MiPoNianYou/UserScripts
  12. // @supportURL https://github.com/MiPoNianYou/UserScripts/issues
  13. // @license GPL-3.0
  14. // @match https://github.com/*
  15. // @exclude https://github.com/topics/*
  16. // @grant none
  17. // @run-at document-idle
  18. // ==/UserScript==
  19.  
  20. (function () {
  21. "use strict";
  22.  
  23. const translations = {
  24. invalidDate: {
  25. "zh-CN": "无效日期",
  26. "zh-TW": "無效日期",
  27. en: "Invalid Date",
  28. },
  29. fullDateLabel: {
  30. "zh-CN": "完整日期",
  31. "zh-TW": "完整日期",
  32. en: "Full Date",
  33. },
  34. };
  35. const PROCESSED_MARKER_CLASS = "gh-time-converter-processed";
  36. const TOOLTIP_ID = "gh-time-converter-tooltip";
  37. const TOOLTIP_STYLE_ID = `${TOOLTIP_ID}-style`;
  38. const TOOLTIP_OFFSET_Y = 5;
  39. const VIEWPORT_MARGIN = 5;
  40.  
  41. let tooltipElement = null;
  42. let currentLanguage = "en";
  43.  
  44. function detectLanguage() {
  45. const langs = navigator.languages || [navigator.language || "en"];
  46. for (const lang of langs) {
  47. const lowerLang = lang.toLowerCase();
  48. if (lowerLang.startsWith("zh-cn")) return "zh-CN";
  49. if (lowerLang.startsWith("zh-tw") || lowerLang.startsWith("zh-hk"))
  50. return "zh-TW";
  51. if (lowerLang.startsWith("zh")) return "zh-CN";
  52. if (lowerLang.startsWith("en")) return "en";
  53. }
  54. return "en";
  55. }
  56.  
  57. function getStr(key) {
  58. return translations[key]?.[currentLanguage] || translations[key]?.en || key;
  59. }
  60.  
  61. function pad(num) {
  62. return String(num).padStart(2, "0");
  63. }
  64.  
  65. function formatDateTime(dateInput, formatType = "short") {
  66. const date =
  67. typeof dateInput === "string" ? new Date(dateInput) : dateInput;
  68. if (isNaN(date.getTime())) {
  69. return getStr("invalidDate");
  70. }
  71. const year = date.getFullYear();
  72. const month = pad(date.getMonth() + 1);
  73. const day = pad(date.getDate());
  74. const hours = pad(date.getHours());
  75. const minutes = pad(date.getMinutes());
  76. if (formatType === "full") {
  77. return `${year}-${month}-${day} ${hours}:${minutes}`;
  78. }
  79. return `${month}-${day} ${hours}:${minutes}`;
  80. }
  81.  
  82. function injectTooltipStyles() {
  83. if (document.getElementById(TOOLTIP_STYLE_ID)) return;
  84.  
  85. const style = document.createElement("style");
  86. style.id = TOOLTIP_STYLE_ID;
  87. style.textContent = `
  88. #${TOOLTIP_ID} {
  89. position: absolute;
  90. background-color: var(--color-canvas-overlay, #222);
  91. color: var(--color-fg-default, #eee);
  92. padding: 5px 8px;
  93. border-radius: 6px;
  94. font-size: 12px;
  95. line-height: 1.4;
  96. z-index: 10000;
  97. pointer-events: none;
  98. white-space: pre;
  99. display: none;
  100. border: 1px solid var(--color-border-default, #444);
  101. box-shadow: 0 1px 3px rgba(0,0,0,0.15);
  102. max-width: 300px;
  103. transition: opacity 0.1s ease-in-out;
  104. opacity: 0;
  105. }
  106. #${TOOLTIP_ID}.visible {
  107. display: block;
  108. opacity: 1;
  109. }
  110. .${PROCESSED_MARKER_CLASS}[data-full-date] {
  111. color: inherit;
  112. font-size: inherit;
  113. display: inline-block;
  114. vertical-align: baseline;
  115. font-family: monospace;
  116. min-width: 85px;
  117. text-align: right;
  118. margin: 0;
  119. padding: 0;
  120. box-sizing: border-box;
  121. cursor: help;
  122. }
  123. `;
  124. document.head.appendChild(style);
  125. }
  126.  
  127. function createTooltipElement() {
  128. let element = document.getElementById(TOOLTIP_ID);
  129. if (!element) {
  130. element = document.createElement("div");
  131. element.id = TOOLTIP_ID;
  132. document.body.appendChild(element);
  133. }
  134. return element;
  135. }
  136.  
  137. function showTooltip(targetSpan) {
  138. const fullDate = targetSpan.dataset.fullDate;
  139. if (!fullDate || !tooltipElement) return;
  140.  
  141. const fullDateLabel = getStr("fullDateLabel");
  142. tooltipElement.textContent = `${fullDateLabel} ${fullDate}`;
  143.  
  144. const rect = targetSpan.getBoundingClientRect();
  145.  
  146. tooltipElement.classList.add("visible");
  147. tooltipElement.style.left = "-9999px";
  148. tooltipElement.style.top = "-9999px";
  149.  
  150. const tooltipWidth = tooltipElement.offsetWidth;
  151. const tooltipHeight = tooltipElement.offsetHeight;
  152.  
  153. const viewportWidth = window.innerWidth;
  154. const viewportHeight = window.innerHeight;
  155.  
  156. let desiredLeft = rect.left + rect.width / 2 - tooltipWidth / 2;
  157. const minLeft = VIEWPORT_MARGIN;
  158. const maxLeft = viewportWidth - tooltipWidth - VIEWPORT_MARGIN;
  159. desiredLeft = Math.max(minLeft, Math.min(desiredLeft, maxLeft));
  160.  
  161. let desiredTop;
  162. const spaceAbove = rect.top - TOOLTIP_OFFSET_Y;
  163. const spaceBelow = viewportHeight - rect.bottom - TOOLTIP_OFFSET_Y;
  164.  
  165. const fitsAbove = spaceAbove >= tooltipHeight + VIEWPORT_MARGIN;
  166. const fitsBelow = spaceBelow >= tooltipHeight + VIEWPORT_MARGIN;
  167.  
  168. if (fitsAbove) {
  169. desiredTop = rect.top - tooltipHeight - TOOLTIP_OFFSET_Y;
  170. } else if (fitsBelow) {
  171. desiredTop = rect.bottom + TOOLTIP_OFFSET_Y;
  172. } else {
  173. if (spaceAbove > spaceBelow) {
  174. desiredTop = Math.max(
  175. VIEWPORT_MARGIN,
  176. rect.top - tooltipHeight - TOOLTIP_OFFSET_Y
  177. );
  178. } else {
  179. desiredTop = Math.min(
  180. viewportHeight - tooltipHeight - VIEWPORT_MARGIN,
  181. rect.bottom + TOOLTIP_OFFSET_Y
  182. );
  183. if (desiredTop < VIEWPORT_MARGIN) {
  184. desiredTop = VIEWPORT_MARGIN;
  185. }
  186. }
  187. }
  188.  
  189. tooltipElement.style.left = `${desiredLeft}px`;
  190. tooltipElement.style.top = `${desiredTop}px`;
  191. }
  192.  
  193. function hideTooltip() {
  194. if (tooltipElement) {
  195. tooltipElement.classList.remove("visible");
  196. }
  197. }
  198.  
  199. function processTimeElement(element) {
  200. if (
  201. !element ||
  202. !(element instanceof Element) ||
  203. element.classList.contains(PROCESSED_MARKER_CLASS)
  204. ) {
  205. return;
  206. }
  207.  
  208. const dateTimeString = element.getAttribute("datetime");
  209. if (!dateTimeString) {
  210. element.classList.add(PROCESSED_MARKER_CLASS);
  211. return;
  212. }
  213.  
  214. try {
  215. const formattedTime = formatDateTime(dateTimeString, "short");
  216. const fullFormattedTime = formatDateTime(dateTimeString, "full");
  217.  
  218. if (formattedTime === getStr("invalidDate")) {
  219. element.classList.add(PROCESSED_MARKER_CLASS);
  220. return;
  221. }
  222.  
  223. const newSpan = document.createElement("span");
  224. newSpan.textContent = formattedTime;
  225. newSpan.dataset.fullDate = fullFormattedTime;
  226.  
  227. newSpan.classList.add(PROCESSED_MARKER_CLASS);
  228.  
  229. if (element.parentNode) {
  230. element.parentNode.replaceChild(newSpan, element);
  231. }
  232. } catch (error) {
  233. element.classList.add(PROCESSED_MARKER_CLASS);
  234. }
  235. }
  236.  
  237. function convertRelativeTimes(rootNode = document.body) {
  238. const timeElements = rootNode.querySelectorAll(
  239. `relative-time:not(.${PROCESSED_MARKER_CLASS})`
  240. );
  241. timeElements.forEach(processTimeElement);
  242. }
  243.  
  244. function initializeEventListeners() {
  245. document.body.addEventListener("mouseover", (event) => {
  246. const targetSpan = event.target.closest(
  247. `span.${PROCESSED_MARKER_CLASS}[data-full-date]`
  248. );
  249. if (targetSpan) {
  250. showTooltip(targetSpan);
  251. }
  252. });
  253.  
  254. document.body.addEventListener("mouseout", (event) => {
  255. const targetSpan = event.target.closest(
  256. `span.${PROCESSED_MARKER_CLASS}[data-full-date]`
  257. );
  258. if (targetSpan) {
  259. hideTooltip();
  260. }
  261. });
  262. }
  263.  
  264. currentLanguage = detectLanguage();
  265. injectTooltipStyles();
  266. tooltipElement = createTooltipElement();
  267. convertRelativeTimes(document.body);
  268. initializeEventListeners();
  269.  
  270. const observer = new MutationObserver((mutations) => {
  271. for (const mutation of mutations) {
  272. if (mutation.type === "childList") {
  273. mutation.addedNodes.forEach((node) => {
  274. if (node.nodeType === Node.ELEMENT_NODE) {
  275. const elementsToProcess = [];
  276. if (node.matches(`relative-time:not(.${PROCESSED_MARKER_CLASS})`)) {
  277. elementsToProcess.push(node);
  278. }
  279. node
  280. .querySelectorAll(`relative-time:not(.${PROCESSED_MARKER_CLASS})`)
  281. .forEach((el) => elementsToProcess.push(el));
  282.  
  283. elementsToProcess.forEach(processTimeElement);
  284. }
  285. });
  286. }
  287. }
  288. });
  289.  
  290. const observerConfig = {
  291. childList: true,
  292. subtree: true,
  293. };
  294. observer.observe(document.body, observerConfig);
  295. })();