Github 时间格式转换

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

目前为 2025-05-04 提交的版本。查看 最新版本

// ==UserScript==
// @name               Github Time Format Converter
// @name:zh-CN         Github 时间格式转换
// @name:zh-TW         Github 時間格式轉換
// @description        Convert relative times on GitHub to absolute date and time
// @description:zh-CN  将 GitHub 页面上的相对时间转换为绝对日期和时间
// @description:zh-TW  將 GitHub 頁面上的相對時間轉換成絕對日期與時間
// @version            1.1.0
// @icon               https://raw.githubusercontent.com/MiPoNianYou/UserScripts/refs/heads/main/Icons/GithubTimeFormatConverterIcon.svg
// @author             念柚
// @namespace          https://github.com/MiPoNianYou/UserScripts
// @supportURL         https://github.com/MiPoNianYou/UserScripts/issues
// @license            GPL-3.0
// @match              https://github.com/*
// @exclude            https://github.com/topics/*
// @grant              GM_addStyle
// @run-at             document-idle
// ==/UserScript==

(function () {
  "use strict";

  const ScriptConfiguration = {
    TOOLTIP_VERTICAL_OFFSET: 5,
    VIEWPORT_EDGE_MARGIN: 5,
    TRANSITION_DURATION_MS: 100,
    UI_FONT_STACK: "-apple-system, BlinkMacSystemFont, system-ui, sans-serif",
    UI_FONT_STACK_MONO: "ui-monospace, SFMono-Regular, Menlo, monospace",
  };

  const ElementIdentifiers = {
    TOOLTIP_CONTAINER_ID: "TimeConverterTooltipContainer",
  };

  const StyleClasses = {
    PROCESSED_TIME_ELEMENT: "time-converter-processed-element",
    TOOLTIP_IS_VISIBLE: "time-converter-tooltip--is-visible",
  };

  const UserInterfaceTextKeys = {
    "zh-CN": {
      INVALID_DATE_STRING: "无效日期",
      FULL_DATE_TIME_LABEL: "完整日期:",
    },
    "zh-TW": {
      INVALID_DATE_STRING: "無效日期",
      FULL_DATE_TIME_LABEL: "完整日期:",
    },
    "en-US": {
      INVALID_DATE_STRING: "Invalid Date",
      FULL_DATE_TIME_LABEL: "Full Date:",
    },
  };

  const DomQuerySelectors = {
    UNPROCESSED_RELATIVE_TIME: `relative-time:not(.${StyleClasses.PROCESSED_TIME_ELEMENT})`,
    PROCESSED_TIME_SPAN: `span.${StyleClasses.PROCESSED_TIME_ELEMENT}[data-full-date-time]`,
    RELATIVE_TIME_TAG: "relative-time",
  };

  let tooltipContainerElement = null;
  let currentUserLocale = "en-US";
  let localizedText = UserInterfaceTextKeys["en-US"];
  let shortDateTimeFormatter = null;
  let fullDateTimeFormatter = null;

  function detectBrowserLanguage() {
    const languages = navigator.languages || [navigator.language];
    for (const lang of languages) {
      const langLower = lang.toLowerCase();
      if (langLower === "zh-cn") return "zh-CN";
      if (
        langLower === "zh-tw" ||
        langLower === "zh-hk" ||
        langLower === "zh-mo"
      )
        return "zh-TW";
      if (langLower === "en-us") return "en-US";
      if (langLower.startsWith("zh-")) return "zh-CN";
      if (langLower.startsWith("en-")) return "en-US";
    }
    for (const lang of languages) {
      const langLower = lang.toLowerCase();
      if (langLower.startsWith("zh")) return "zh-CN";
      if (langLower.startsWith("en")) return "en-US";
    }
    return "en-US";
  }

  function initializeDateTimeFormatters(locale) {
    try {
      shortDateTimeFormatter = new Intl.DateTimeFormat(locale, {
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
        hour12: false,
      });

      fullDateTimeFormatter = new Intl.DateTimeFormat(locale, {
        year: "numeric",
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
        hour12: false,
      });
    } catch (e) {
      shortDateTimeFormatter = null;
      fullDateTimeFormatter = null;
    }
  }

  function getLocalizedTextForKey(key) {
    return (
      localizedText[key] ||
      UserInterfaceTextKeys["en-US"][key] ||
      key.replace(/_/g, " ")
    );
  }

  function formatDateTimeString(dateSource, formatStyle = "short") {
    const dateObject =
      typeof dateSource === "string" ? new Date(dateSource) : dateSource;

    if (isNaN(dateObject.getTime())) {
      return getLocalizedTextForKey("INVALID_DATE_STRING");
    }

    const formatter =
      formatStyle === "full" ? fullDateTimeFormatter : shortDateTimeFormatter;

    if (!formatter) {
      const year = dateObject.getFullYear();
      const month = String(dateObject.getMonth() + 1).padStart(2, "0");
      const day = String(dateObject.getDate()).padStart(2, "0");
      const hours = String(dateObject.getHours()).padStart(2, "0");
      const minutes = String(dateObject.getMinutes()).padStart(2, "0");
      if (formatStyle === "full") {
        return `${year}-${month}-${day} ${hours}:${minutes}`;
      }
      return `${month}-${day} ${hours}:${minutes}`;
    }

    try {
      let formattedString = formatter.format(dateObject);
      formattedString = formattedString.replace(/[/]/g, "-");
      if (formatStyle === "full") {
        formattedString = formattedString.replace(", ", " ");
      } else {
        formattedString = formattedString.replace(", ", " ");
      }
      return formattedString;
    } catch (e) {
      return getLocalizedTextForKey("INVALID_DATE_STRING");
    }
  }

  function injectDynamicStyles() {
    const appleEaseOutStandard = "cubic-bezier(0, 0, 0.58, 1)";
    const transitionDuration = ScriptConfiguration.TRANSITION_DURATION_MS;

    const cssStyles = `
      :root {
        --time-converter-tooltip-background-color-dark: rgba(48, 52, 70, 0.92);
        --time-converter-tooltip-text-color-dark: #c6d0f5;
        --time-converter-tooltip-border-color-dark: rgba(98, 104, 128, 0.25);
        --time-converter-tooltip-shadow-dark: 0 1px 3px rgba(0, 0, 0, 0.15), 0 5px 10px rgba(0, 0, 0, 0.2);

        --time-converter-tooltip-background-color-light: rgba(239, 241, 245, 0.92);
        --time-converter-tooltip-text-color-light: #4c4f69;
        --time-converter-tooltip-border-color-light: rgba(172, 176, 190, 0.3);
        --time-converter-tooltip-shadow-light: 0 1px 3px rgba(90, 90, 90, 0.08), 0 5px 10px rgba(90, 90, 90, 0.12);
      }

      #${ElementIdentifiers.TOOLTIP_CONTAINER_ID} {
        position: fixed;
        padding: 6px 10px;
        border-radius: 8px;
        font-size: 12px;
        line-height: 1.4;
        z-index: 2147483647;
        pointer-events: none;
        white-space: pre;
        max-width: 350px;
        opacity: 0;
        visibility: hidden;
        font-family: ${ScriptConfiguration.UI_FONT_STACK};
        backdrop-filter: blur(10px) saturate(180%);
        -webkit-backdrop-filter: blur(10px) saturate(180%);
        transition: opacity ${transitionDuration}ms ${appleEaseOutStandard},
                    visibility ${transitionDuration}ms ${appleEaseOutStandard};

        background-color: var(--time-converter-tooltip-background-color-dark);
        color: var(--time-converter-tooltip-text-color-dark);
        border: 1px solid var(--time-converter-tooltip-border-color-dark);
        box-shadow: var(--time-converter-tooltip-shadow-dark);
      }

      #${ElementIdentifiers.TOOLTIP_CONTAINER_ID}.${StyleClasses.TOOLTIP_IS_VISIBLE} {
        opacity: 1;
        visibility: visible;
      }

      .${StyleClasses.PROCESSED_TIME_ELEMENT}[data-full-date-time] {
        display: inline-block;
        vertical-align: baseline;
        font-family: ${ScriptConfiguration.UI_FONT_STACK_MONO};
        min-width: 88px;
        text-align: right;
        margin: 0;
        padding: 0;
        box-sizing: border-box;
        cursor: help;
        color: inherit;
        background: none;
        border: none;
      }

      @media (prefers-color-scheme: light) {
        #${ElementIdentifiers.TOOLTIP_CONTAINER_ID} {
          background-color: var(--time-converter-tooltip-background-color-light);
          color: var(--time-converter-tooltip-text-color-light);
          border: 1px solid var(--time-converter-tooltip-border-color-light);
          box-shadow: var(--time-converter-tooltip-shadow-light);
        }
      }
    `;
    try {
      GM_addStyle(cssStyles);
    } catch (e) {}
  }

  function ensureTooltipContainerExists() {
    tooltipContainerElement = document.getElementById(
      ElementIdentifiers.TOOLTIP_CONTAINER_ID
    );
    if (!tooltipContainerElement && document.body) {
      tooltipContainerElement = document.createElement("div");
      tooltipContainerElement.id = ElementIdentifiers.TOOLTIP_CONTAINER_ID;
      tooltipContainerElement.setAttribute("role", "tooltip");
      tooltipContainerElement.setAttribute("aria-hidden", "true");
      try {
        if (document.body) {
          document.body.appendChild(tooltipContainerElement);
        }
      } catch (e) {}
    }
    return tooltipContainerElement;
  }

  function displayTooltipNearElement(targetElement) {
    const fullDateTime = targetElement.dataset.fullDateTime;
    ensureTooltipContainerExists();

    if (!fullDateTime || !tooltipContainerElement) return;

    const label = getLocalizedTextForKey("FULL_DATE_TIME_LABEL");
    tooltipContainerElement.textContent = `${label} ${fullDateTime}`;
    tooltipContainerElement.setAttribute("aria-hidden", "false");

    const targetRect = targetElement.getBoundingClientRect();
    tooltipContainerElement.classList.add(StyleClasses.TOOLTIP_IS_VISIBLE);

    tooltipContainerElement.style.left = "-9999px";
    tooltipContainerElement.style.top = "-9999px";
    tooltipContainerElement.style.visibility = "hidden";

    requestAnimationFrame(() => {
      if (!tooltipContainerElement || !targetElement.isConnected) {
        hideTooltip();
        return;
      }

      const tooltipWidth = tooltipContainerElement.offsetWidth;
      const tooltipHeight = tooltipContainerElement.offsetHeight;
      const viewportWidth = window.innerWidth;
      const viewportHeight = window.innerHeight;

      const verticalOffset = ScriptConfiguration.TOOLTIP_VERTICAL_OFFSET;
      const margin = ScriptConfiguration.VIEWPORT_EDGE_MARGIN;

      let tooltipLeft =
        targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
      tooltipLeft = Math.max(margin, tooltipLeft);
      tooltipLeft = Math.min(
        viewportWidth - tooltipWidth - margin,
        tooltipLeft
      );

      let tooltipTop;
      const spaceAbove = targetRect.top - verticalOffset;
      const spaceBelow = viewportHeight - targetRect.bottom - verticalOffset;

      if (spaceAbove >= tooltipHeight + margin) {
        tooltipTop = targetRect.top - tooltipHeight - verticalOffset;
      } else if (spaceBelow >= tooltipHeight + margin) {
        tooltipTop = targetRect.bottom + verticalOffset;
      } else {
        if (spaceAbove > spaceBelow) {
          tooltipTop = Math.max(
            margin,
            targetRect.top - tooltipHeight - verticalOffset
          );
        } else {
          tooltipTop = Math.min(
            viewportHeight - tooltipHeight - margin,
            targetRect.bottom + verticalOffset
          );
          if (tooltipTop < margin) {
            tooltipTop = margin;
          }
        }
      }

      tooltipContainerElement.style.left = `${tooltipLeft}px`;
      tooltipContainerElement.style.top = `${tooltipTop}px`;
      tooltipContainerElement.style.visibility = "visible";
    });
  }

  function hideTooltip() {
    if (tooltipContainerElement) {
      tooltipContainerElement.classList.remove(StyleClasses.TOOLTIP_IS_VISIBLE);
      tooltipContainerElement.setAttribute("aria-hidden", "true");
    }
  }

  function convertRelativeTimeElement(element) {
    if (
      !element ||
      !(element instanceof Element) ||
      element.classList.contains(StyleClasses.PROCESSED_TIME_ELEMENT)
    ) {
      return;
    }

    const dateTimeAttribute = element.getAttribute("datetime");
    if (!dateTimeAttribute) {
      element.classList.add(StyleClasses.PROCESSED_TIME_ELEMENT);
      return;
    }

    try {
      const shortFormattedTime = formatDateTimeString(
        dateTimeAttribute,
        "short"
      );
      const fullFormattedTime = formatDateTimeString(dateTimeAttribute, "full");

      if (
        shortFormattedTime === getLocalizedTextForKey("INVALID_DATE_STRING") ||
        fullFormattedTime === getLocalizedTextForKey("INVALID_DATE_STRING")
      ) {
        element.classList.add(StyleClasses.PROCESSED_TIME_ELEMENT);
        return;
      }

      const replacementSpan = document.createElement("span");
      replacementSpan.textContent = shortFormattedTime;
      replacementSpan.dataset.fullDateTime = fullFormattedTime;
      replacementSpan.classList.add(StyleClasses.PROCESSED_TIME_ELEMENT);

      if (element.parentNode) {
        element.parentNode.replaceChild(replacementSpan, element);
      } else {
        element.classList.add(StyleClasses.PROCESSED_TIME_ELEMENT);
      }
    } catch (error) {
      element.classList.add(StyleClasses.PROCESSED_TIME_ELEMENT);
    }
  }

  function processRelativeTimesInNode(targetNode = document.body) {
    if (!targetNode || typeof targetNode.querySelectorAll !== "function") {
      return;
    }
    try {
      const timeElements = targetNode.querySelectorAll(
        DomQuerySelectors.UNPROCESSED_RELATIVE_TIME
      );
      timeElements.forEach(convertRelativeTimeElement);
    } catch (e) {}
  }

  function setupTooltipInteractionListeners() {
    document.body.addEventListener("mouseover", (event) => {
      const targetSpan = event.target.closest(
        DomQuerySelectors.PROCESSED_TIME_SPAN
      );
      if (targetSpan) {
        displayTooltipNearElement(targetSpan);
      }
    });

    document.body.addEventListener("mouseout", (event) => {
      const targetSpan = event.target.closest(
        DomQuerySelectors.PROCESSED_TIME_SPAN
      );
      if (
        targetSpan &&
        (!event.relatedTarget ||
          !tooltipContainerElement?.contains(event.relatedTarget))
      ) {
        hideTooltip();
      }
    });

    document.body.addEventListener(
      "focusin",
      (event) => {
        const targetSpan = event.target.closest(
          DomQuerySelectors.PROCESSED_TIME_SPAN
        );
        if (targetSpan) {
          displayTooltipNearElement(targetSpan);
        }
      },
      true
    );

    document.body.addEventListener(
      "focusout",
      (event) => {
        const targetSpan = event.target.closest(
          DomQuerySelectors.PROCESSED_TIME_SPAN
        );
        if (targetSpan) {
          hideTooltip();
        }
      },
      true
    );
  }

  function startObservingDomMutations() {
    const mutationObserver = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
          for (const addedNode of mutation.addedNodes) {
            if (addedNode.nodeType === Node.ELEMENT_NODE) {
              if (
                addedNode.matches(DomQuerySelectors.UNPROCESSED_RELATIVE_TIME)
              ) {
                convertRelativeTimeElement(addedNode);
              } else if (
                addedNode.querySelector(
                  DomQuerySelectors.UNPROCESSED_RELATIVE_TIME
                )
              ) {
                const descendantElements = addedNode.querySelectorAll(
                  DomQuerySelectors.UNPROCESSED_RELATIVE_TIME
                );
                descendantElements.forEach(convertRelativeTimeElement);
              }
            }
          }
        }
      }
    });

    const observerConfiguration = {
      childList: true,
      subtree: true,
    };

    if (document.body) {
      try {
        mutationObserver.observe(document.body, observerConfiguration);
      } catch (e) {}
    } else {
      document.addEventListener(
        "DOMContentLoaded",
        () => {
          if (document.body) {
            try {
              mutationObserver.observe(document.body, observerConfiguration);
            } catch (e) {}
          }
        },
        { once: true }
      );
    }
  }

  function initializeTimeConverterScript() {
    currentUserLocale = detectBrowserLanguage();
    localizedText =
      UserInterfaceTextKeys[currentUserLocale] ||
      UserInterfaceTextKeys["en-US"];
    initializeDateTimeFormatters(currentUserLocale);
    injectDynamicStyles();
    ensureTooltipContainerExists();
    processRelativeTimesInNode(document.body);
    setupTooltipInteractionListeners();
    startObservingDomMutations();
  }

  if (
    document.readyState === "complete" ||
    (document.readyState !== "loading" && !document.documentElement.doScroll)
  ) {
    initializeTimeConverterScript();
  } else {
    document.addEventListener(
      "DOMContentLoaded",
      initializeTimeConverterScript,
      { once: true }
    );
  }
})();