Github 时间格式转换

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

  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.1.0
  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 GM_addStyle
  17. // @run-at document-idle
  18. // ==/UserScript==
  19.  
  20. (function () {
  21. "use strict";
  22.  
  23. const ScriptConfiguration = {
  24. TOOLTIP_VERTICAL_OFFSET: 5,
  25. VIEWPORT_EDGE_MARGIN: 5,
  26. TRANSITION_DURATION_MS: 100,
  27. UI_FONT_STACK: "-apple-system, BlinkMacSystemFont, system-ui, sans-serif",
  28. UI_FONT_STACK_MONO: "ui-monospace, SFMono-Regular, Menlo, monospace",
  29. };
  30.  
  31. const ElementIdentifiers = {
  32. TOOLTIP_CONTAINER_ID: "TimeConverterTooltipContainer",
  33. };
  34.  
  35. const StyleClasses = {
  36. PROCESSED_TIME_ELEMENT: "time-converter-processed-element",
  37. TOOLTIP_IS_VISIBLE: "time-converter-tooltip--is-visible",
  38. };
  39.  
  40. const UserInterfaceTextKeys = {
  41. "zh-CN": {
  42. INVALID_DATE_STRING: "无效日期",
  43. FULL_DATE_TIME_LABEL: "完整日期:",
  44. },
  45. "zh-TW": {
  46. INVALID_DATE_STRING: "無效日期",
  47. FULL_DATE_TIME_LABEL: "完整日期:",
  48. },
  49. "en-US": {
  50. INVALID_DATE_STRING: "Invalid Date",
  51. FULL_DATE_TIME_LABEL: "Full Date:",
  52. },
  53. };
  54.  
  55. const DomQuerySelectors = {
  56. UNPROCESSED_RELATIVE_TIME: `relative-time:not(.${StyleClasses.PROCESSED_TIME_ELEMENT})`,
  57. PROCESSED_TIME_SPAN: `span.${StyleClasses.PROCESSED_TIME_ELEMENT}[data-full-date-time]`,
  58. RELATIVE_TIME_TAG: "relative-time",
  59. };
  60.  
  61. let tooltipContainerElement = null;
  62. let currentUserLocale = "en-US";
  63. let localizedText = UserInterfaceTextKeys["en-US"];
  64. let shortDateTimeFormatter = null;
  65. let fullDateTimeFormatter = null;
  66.  
  67. function detectBrowserLanguage() {
  68. const languages = navigator.languages || [navigator.language];
  69. for (const lang of languages) {
  70. const langLower = lang.toLowerCase();
  71. if (langLower === "zh-cn") return "zh-CN";
  72. if (
  73. langLower === "zh-tw" ||
  74. langLower === "zh-hk" ||
  75. langLower === "zh-mo"
  76. )
  77. return "zh-TW";
  78. if (langLower === "en-us") return "en-US";
  79. if (langLower.startsWith("zh-")) return "zh-CN";
  80. if (langLower.startsWith("en-")) return "en-US";
  81. }
  82. for (const lang of languages) {
  83. const langLower = lang.toLowerCase();
  84. if (langLower.startsWith("zh")) return "zh-CN";
  85. if (langLower.startsWith("en")) return "en-US";
  86. }
  87. return "en-US";
  88. }
  89.  
  90. function initializeDateTimeFormatters(locale) {
  91. try {
  92. shortDateTimeFormatter = new Intl.DateTimeFormat(locale, {
  93. month: "2-digit",
  94. day: "2-digit",
  95. hour: "2-digit",
  96. minute: "2-digit",
  97. hour12: false,
  98. });
  99.  
  100. fullDateTimeFormatter = new Intl.DateTimeFormat(locale, {
  101. year: "numeric",
  102. month: "2-digit",
  103. day: "2-digit",
  104. hour: "2-digit",
  105. minute: "2-digit",
  106. hour12: false,
  107. });
  108. } catch (e) {
  109. shortDateTimeFormatter = null;
  110. fullDateTimeFormatter = null;
  111. }
  112. }
  113.  
  114. function getLocalizedTextForKey(key) {
  115. return (
  116. localizedText[key] ||
  117. UserInterfaceTextKeys["en-US"][key] ||
  118. key.replace(/_/g, " ")
  119. );
  120. }
  121.  
  122. function formatDateTimeString(dateSource, formatStyle = "short") {
  123. const dateObject =
  124. typeof dateSource === "string" ? new Date(dateSource) : dateSource;
  125.  
  126. if (isNaN(dateObject.getTime())) {
  127. return getLocalizedTextForKey("INVALID_DATE_STRING");
  128. }
  129.  
  130. const formatter =
  131. formatStyle === "full" ? fullDateTimeFormatter : shortDateTimeFormatter;
  132.  
  133. if (!formatter) {
  134. const year = dateObject.getFullYear();
  135. const month = String(dateObject.getMonth() + 1).padStart(2, "0");
  136. const day = String(dateObject.getDate()).padStart(2, "0");
  137. const hours = String(dateObject.getHours()).padStart(2, "0");
  138. const minutes = String(dateObject.getMinutes()).padStart(2, "0");
  139. if (formatStyle === "full") {
  140. return `${year}-${month}-${day} ${hours}:${minutes}`;
  141. }
  142. return `${month}-${day} ${hours}:${minutes}`;
  143. }
  144.  
  145. try {
  146. let formattedString = formatter.format(dateObject);
  147. formattedString = formattedString.replace(/[/]/g, "-");
  148. if (formatStyle === "full") {
  149. formattedString = formattedString.replace(", ", " ");
  150. } else {
  151. formattedString = formattedString.replace(", ", " ");
  152. }
  153. return formattedString;
  154. } catch (e) {
  155. return getLocalizedTextForKey("INVALID_DATE_STRING");
  156. }
  157. }
  158.  
  159. function injectDynamicStyles() {
  160. const appleEaseOutStandard = "cubic-bezier(0, 0, 0.58, 1)";
  161. const transitionDuration = ScriptConfiguration.TRANSITION_DURATION_MS;
  162.  
  163. const cssStyles = `
  164. :root {
  165. --time-converter-tooltip-background-color-dark: rgba(48, 52, 70, 0.92);
  166. --time-converter-tooltip-text-color-dark: #c6d0f5;
  167. --time-converter-tooltip-border-color-dark: rgba(98, 104, 128, 0.25);
  168. --time-converter-tooltip-shadow-dark: 0 1px 3px rgba(0, 0, 0, 0.15), 0 5px 10px rgba(0, 0, 0, 0.2);
  169.  
  170. --time-converter-tooltip-background-color-light: rgba(239, 241, 245, 0.92);
  171. --time-converter-tooltip-text-color-light: #4c4f69;
  172. --time-converter-tooltip-border-color-light: rgba(172, 176, 190, 0.3);
  173. --time-converter-tooltip-shadow-light: 0 1px 3px rgba(90, 90, 90, 0.08), 0 5px 10px rgba(90, 90, 90, 0.12);
  174. }
  175.  
  176. #${ElementIdentifiers.TOOLTIP_CONTAINER_ID} {
  177. position: fixed;
  178. padding: 6px 10px;
  179. border-radius: 8px;
  180. font-size: 12px;
  181. line-height: 1.4;
  182. z-index: 2147483647;
  183. pointer-events: none;
  184. white-space: pre;
  185. max-width: 350px;
  186. opacity: 0;
  187. visibility: hidden;
  188. font-family: ${ScriptConfiguration.UI_FONT_STACK};
  189. backdrop-filter: blur(10px) saturate(180%);
  190. -webkit-backdrop-filter: blur(10px) saturate(180%);
  191. transition: opacity ${transitionDuration}ms ${appleEaseOutStandard},
  192. visibility ${transitionDuration}ms ${appleEaseOutStandard};
  193.  
  194. background-color: var(--time-converter-tooltip-background-color-dark);
  195. color: var(--time-converter-tooltip-text-color-dark);
  196. border: 1px solid var(--time-converter-tooltip-border-color-dark);
  197. box-shadow: var(--time-converter-tooltip-shadow-dark);
  198. }
  199.  
  200. #${ElementIdentifiers.TOOLTIP_CONTAINER_ID}.${StyleClasses.TOOLTIP_IS_VISIBLE} {
  201. opacity: 1;
  202. visibility: visible;
  203. }
  204.  
  205. .${StyleClasses.PROCESSED_TIME_ELEMENT}[data-full-date-time] {
  206. display: inline-block;
  207. vertical-align: baseline;
  208. font-family: ${ScriptConfiguration.UI_FONT_STACK_MONO};
  209. min-width: 88px;
  210. text-align: right;
  211. margin: 0;
  212. padding: 0;
  213. box-sizing: border-box;
  214. cursor: help;
  215. color: inherit;
  216. background: none;
  217. border: none;
  218. }
  219.  
  220. @media (prefers-color-scheme: light) {
  221. #${ElementIdentifiers.TOOLTIP_CONTAINER_ID} {
  222. background-color: var(--time-converter-tooltip-background-color-light);
  223. color: var(--time-converter-tooltip-text-color-light);
  224. border: 1px solid var(--time-converter-tooltip-border-color-light);
  225. box-shadow: var(--time-converter-tooltip-shadow-light);
  226. }
  227. }
  228. `;
  229. try {
  230. GM_addStyle(cssStyles);
  231. } catch (e) {}
  232. }
  233.  
  234. function ensureTooltipContainerExists() {
  235. tooltipContainerElement = document.getElementById(
  236. ElementIdentifiers.TOOLTIP_CONTAINER_ID
  237. );
  238. if (!tooltipContainerElement && document.body) {
  239. tooltipContainerElement = document.createElement("div");
  240. tooltipContainerElement.id = ElementIdentifiers.TOOLTIP_CONTAINER_ID;
  241. tooltipContainerElement.setAttribute("role", "tooltip");
  242. tooltipContainerElement.setAttribute("aria-hidden", "true");
  243. try {
  244. if (document.body) {
  245. document.body.appendChild(tooltipContainerElement);
  246. }
  247. } catch (e) {}
  248. }
  249. return tooltipContainerElement;
  250. }
  251.  
  252. function displayTooltipNearElement(targetElement) {
  253. const fullDateTime = targetElement.dataset.fullDateTime;
  254. ensureTooltipContainerExists();
  255.  
  256. if (!fullDateTime || !tooltipContainerElement) return;
  257.  
  258. const label = getLocalizedTextForKey("FULL_DATE_TIME_LABEL");
  259. tooltipContainerElement.textContent = `${label} ${fullDateTime}`;
  260. tooltipContainerElement.setAttribute("aria-hidden", "false");
  261.  
  262. const targetRect = targetElement.getBoundingClientRect();
  263. tooltipContainerElement.classList.add(StyleClasses.TOOLTIP_IS_VISIBLE);
  264.  
  265. tooltipContainerElement.style.left = "-9999px";
  266. tooltipContainerElement.style.top = "-9999px";
  267. tooltipContainerElement.style.visibility = "hidden";
  268.  
  269. requestAnimationFrame(() => {
  270. if (!tooltipContainerElement || !targetElement.isConnected) {
  271. hideTooltip();
  272. return;
  273. }
  274.  
  275. const tooltipWidth = tooltipContainerElement.offsetWidth;
  276. const tooltipHeight = tooltipContainerElement.offsetHeight;
  277. const viewportWidth = window.innerWidth;
  278. const viewportHeight = window.innerHeight;
  279.  
  280. const verticalOffset = ScriptConfiguration.TOOLTIP_VERTICAL_OFFSET;
  281. const margin = ScriptConfiguration.VIEWPORT_EDGE_MARGIN;
  282.  
  283. let tooltipLeft =
  284. targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
  285. tooltipLeft = Math.max(margin, tooltipLeft);
  286. tooltipLeft = Math.min(
  287. viewportWidth - tooltipWidth - margin,
  288. tooltipLeft
  289. );
  290.  
  291. let tooltipTop;
  292. const spaceAbove = targetRect.top - verticalOffset;
  293. const spaceBelow = viewportHeight - targetRect.bottom - verticalOffset;
  294.  
  295. if (spaceAbove >= tooltipHeight + margin) {
  296. tooltipTop = targetRect.top - tooltipHeight - verticalOffset;
  297. } else if (spaceBelow >= tooltipHeight + margin) {
  298. tooltipTop = targetRect.bottom + verticalOffset;
  299. } else {
  300. if (spaceAbove > spaceBelow) {
  301. tooltipTop = Math.max(
  302. margin,
  303. targetRect.top - tooltipHeight - verticalOffset
  304. );
  305. } else {
  306. tooltipTop = Math.min(
  307. viewportHeight - tooltipHeight - margin,
  308. targetRect.bottom + verticalOffset
  309. );
  310. if (tooltipTop < margin) {
  311. tooltipTop = margin;
  312. }
  313. }
  314. }
  315.  
  316. tooltipContainerElement.style.left = `${tooltipLeft}px`;
  317. tooltipContainerElement.style.top = `${tooltipTop}px`;
  318. tooltipContainerElement.style.visibility = "visible";
  319. });
  320. }
  321.  
  322. function hideTooltip() {
  323. if (tooltipContainerElement) {
  324. tooltipContainerElement.classList.remove(StyleClasses.TOOLTIP_IS_VISIBLE);
  325. tooltipContainerElement.setAttribute("aria-hidden", "true");
  326. }
  327. }
  328.  
  329. function convertRelativeTimeElement(element) {
  330. if (
  331. !element ||
  332. !(element instanceof Element) ||
  333. element.classList.contains(StyleClasses.PROCESSED_TIME_ELEMENT)
  334. ) {
  335. return;
  336. }
  337.  
  338. const dateTimeAttribute = element.getAttribute("datetime");
  339. if (!dateTimeAttribute) {
  340. element.classList.add(StyleClasses.PROCESSED_TIME_ELEMENT);
  341. return;
  342. }
  343.  
  344. try {
  345. const shortFormattedTime = formatDateTimeString(
  346. dateTimeAttribute,
  347. "short"
  348. );
  349. const fullFormattedTime = formatDateTimeString(dateTimeAttribute, "full");
  350.  
  351. if (
  352. shortFormattedTime === getLocalizedTextForKey("INVALID_DATE_STRING") ||
  353. fullFormattedTime === getLocalizedTextForKey("INVALID_DATE_STRING")
  354. ) {
  355. element.classList.add(StyleClasses.PROCESSED_TIME_ELEMENT);
  356. return;
  357. }
  358.  
  359. const replacementSpan = document.createElement("span");
  360. replacementSpan.textContent = shortFormattedTime;
  361. replacementSpan.dataset.fullDateTime = fullFormattedTime;
  362. replacementSpan.classList.add(StyleClasses.PROCESSED_TIME_ELEMENT);
  363.  
  364. if (element.parentNode) {
  365. element.parentNode.replaceChild(replacementSpan, element);
  366. } else {
  367. element.classList.add(StyleClasses.PROCESSED_TIME_ELEMENT);
  368. }
  369. } catch (error) {
  370. element.classList.add(StyleClasses.PROCESSED_TIME_ELEMENT);
  371. }
  372. }
  373.  
  374. function processRelativeTimesInNode(targetNode = document.body) {
  375. if (!targetNode || typeof targetNode.querySelectorAll !== "function") {
  376. return;
  377. }
  378. try {
  379. const timeElements = targetNode.querySelectorAll(
  380. DomQuerySelectors.UNPROCESSED_RELATIVE_TIME
  381. );
  382. timeElements.forEach(convertRelativeTimeElement);
  383. } catch (e) {}
  384. }
  385.  
  386. function setupTooltipInteractionListeners() {
  387. document.body.addEventListener("mouseover", (event) => {
  388. const targetSpan = event.target.closest(
  389. DomQuerySelectors.PROCESSED_TIME_SPAN
  390. );
  391. if (targetSpan) {
  392. displayTooltipNearElement(targetSpan);
  393. }
  394. });
  395.  
  396. document.body.addEventListener("mouseout", (event) => {
  397. const targetSpan = event.target.closest(
  398. DomQuerySelectors.PROCESSED_TIME_SPAN
  399. );
  400. if (
  401. targetSpan &&
  402. (!event.relatedTarget ||
  403. !tooltipContainerElement?.contains(event.relatedTarget))
  404. ) {
  405. hideTooltip();
  406. }
  407. });
  408.  
  409. document.body.addEventListener(
  410. "focusin",
  411. (event) => {
  412. const targetSpan = event.target.closest(
  413. DomQuerySelectors.PROCESSED_TIME_SPAN
  414. );
  415. if (targetSpan) {
  416. displayTooltipNearElement(targetSpan);
  417. }
  418. },
  419. true
  420. );
  421.  
  422. document.body.addEventListener(
  423. "focusout",
  424. (event) => {
  425. const targetSpan = event.target.closest(
  426. DomQuerySelectors.PROCESSED_TIME_SPAN
  427. );
  428. if (targetSpan) {
  429. hideTooltip();
  430. }
  431. },
  432. true
  433. );
  434. }
  435.  
  436. function startObservingDomMutations() {
  437. const mutationObserver = new MutationObserver((mutations) => {
  438. for (const mutation of mutations) {
  439. if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
  440. for (const addedNode of mutation.addedNodes) {
  441. if (addedNode.nodeType === Node.ELEMENT_NODE) {
  442. if (
  443. addedNode.matches(DomQuerySelectors.UNPROCESSED_RELATIVE_TIME)
  444. ) {
  445. convertRelativeTimeElement(addedNode);
  446. } else if (
  447. addedNode.querySelector(
  448. DomQuerySelectors.UNPROCESSED_RELATIVE_TIME
  449. )
  450. ) {
  451. const descendantElements = addedNode.querySelectorAll(
  452. DomQuerySelectors.UNPROCESSED_RELATIVE_TIME
  453. );
  454. descendantElements.forEach(convertRelativeTimeElement);
  455. }
  456. }
  457. }
  458. }
  459. }
  460. });
  461.  
  462. const observerConfiguration = {
  463. childList: true,
  464. subtree: true,
  465. };
  466.  
  467. if (document.body) {
  468. try {
  469. mutationObserver.observe(document.body, observerConfiguration);
  470. } catch (e) {}
  471. } else {
  472. document.addEventListener(
  473. "DOMContentLoaded",
  474. () => {
  475. if (document.body) {
  476. try {
  477. mutationObserver.observe(document.body, observerConfiguration);
  478. } catch (e) {}
  479. }
  480. },
  481. { once: true }
  482. );
  483. }
  484. }
  485.  
  486. function initializeTimeConverterScript() {
  487. currentUserLocale = detectBrowserLanguage();
  488. localizedText =
  489. UserInterfaceTextKeys[currentUserLocale] ||
  490. UserInterfaceTextKeys["en-US"];
  491. initializeDateTimeFormatters(currentUserLocale);
  492. injectDynamicStyles();
  493. ensureTooltipContainerExists();
  494. processRelativeTimesInNode(document.body);
  495. setupTooltipInteractionListeners();
  496. startObservingDomMutations();
  497. }
  498.  
  499. if (
  500. document.readyState === "complete" ||
  501. (document.readyState !== "loading" && !document.documentElement.doScroll)
  502. ) {
  503. initializeTimeConverterScript();
  504. } else {
  505. document.addEventListener(
  506. "DOMContentLoaded",
  507. initializeTimeConverterScript,
  508. { once: true }
  509. );
  510. }
  511. })();