Github Time Format Converter

Convert relative times on GitHub to absolute date and time

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

Advertisement:

// ==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.4.0
// @icon               https://raw.githubusercontent.com/MiPoNianYou/UserScripts/main/Icons/Github-Time-Format-Converter-Icon.svg
// @author             念柚
// @namespace          https://github.com/MiPoNianYou/UserScripts
// @supportURL         https://github.com/MiPoNianYou/UserScripts/issues
// @license            AGPL-3.0
// @match              https://github.com/*
// @exclude            https://github.com/topics/*
// @grant              GM_addStyle
// @run-at             document-idle
// ==/UserScript==

(function () {
  "use strict";

  // ==================== Data Layer ====================
  // 配置、状态管理、日期格式化

  const CONFIG = {
    SETTINGS: {
      TOOLTIP_OFFSET: 8,
      EDGE_MARGIN: 8,
      TRANSITION_MS: 150,
      FONT_STACK: "-apple-system, BlinkMacSystemFont, system-ui, sans-serif",
      FONT_MONO: "ui-monospace, SFMono-Regular, Menlo, monospace",
    },
    IDS: {
      TOOLTIP: "TimeConverterTooltipContainer",
    },
    CLASSES: {
      PROCESSED: "time-converter-processed",
      VISIBLE: "time-converter-visible",
      NO_TRANSLATE: "notranslate",
    },
    SELECTORS: {
      RELATIVE_TIME: "relative-time:not(.time-converter-processed)",
      PROCESSED_SPAN: "span.time-converter-processed[data-tooltip-time]",
    },
    I18N: {
      "zh-CN": { INVALID: "无效日期" },
      "zh-TW": { INVALID: "無效日期" },
      "en-US": { INVALID: "Invalid Date" },
    },
  };

  const state = {
    locale: "en-US",
    formatters: { date: null, time: null },
    tooltip: null,

    init() {
      this.locale = this.detectLocale();
      this.createFormatters();
    },

    detectLocale() {
      const langs = navigator.languages || [navigator.language];
      for (const lang of langs) {
        const lower = lang.toLowerCase();
        if (lower.match(/^zh-(hans|cn)$/) || lower.startsWith("zh-hans") || lower === "zh") return "zh-CN";
        if (lower.match(/^zh-(hant|tw|hk|mo)$/)) return "zh-TW";
        if (lower.startsWith("en")) return "en-US";
      }
      return "en-US";
    },

    createFormatters() {
      try {
        this.formatters.date = new Intl.DateTimeFormat(this.locale, {
          year: "2-digit",
          month: "2-digit",
          day: "2-digit",
        });
        this.formatters.time = new Intl.DateTimeFormat(this.locale, {
          hour: "2-digit",
          minute: "2-digit",
          second: "2-digit",
          hour12: false,
        });
      } catch {
        this.formatters = { date: null, time: null };
      }
    },

    getText(key) {
      return (
        CONFIG.I18N[this.locale]?.[key] ?? CONFIG.I18N["en-US"][key] ?? key
      );
    },
  };

  // ==================== View Layer ====================
  // UI 渲染、提示框、CSS 注入

  const view = {
    injectStyles() {
      const { TRANSITION_MS, FONT_MONO } = CONFIG.SETTINGS;
      const { TOOLTIP } = CONFIG.IDS;
      const { PROCESSED, VISIBLE } = CONFIG.CLASSES;

      GM_addStyle(`
        :root {
          --tooltip-bg-dark: rgba(30, 33, 45, 0.95);
          --tooltip-text-dark: rgb(205, 214, 244);
          --tooltip-border-dark: rgba(108, 112, 134, 0.3);
          --tooltip-shadow-dark: 0 4px 12px rgba(0,0,0,0.25), 0 2px 4px rgba(0,0,0,0.15);

          --tooltip-bg-light: rgba(255, 255, 255, 0.95);
          --tooltip-text-light: rgb(55, 58, 80);
          --tooltip-border-light: rgba(200, 205, 220, 0.4);
          --tooltip-shadow-light: 0 4px 12px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06);
        }

        #${TOOLTIP} {
          position: fixed;
          padding: 8px 12px;
          border-radius: 6px;
          font: 12px/1.5 ${FONT_MONO};
          z-index: 2147483647;
          pointer-events: none;
          white-space: pre;
          max-width: 350px;
          opacity: 0;
          visibility: hidden;
          transform: translateY(4px);
          backdrop-filter: blur(12px) saturate(200%);
          -webkit-backdrop-filter: blur(12px) saturate(200%);
          transition: opacity ${TRANSITION_MS}ms cubic-bezier(0.16, 1, 0.3, 1),
                      visibility ${TRANSITION_MS}ms cubic-bezier(0.16, 1, 0.3, 1),
                      transform ${TRANSITION_MS}ms cubic-bezier(0.16, 1, 0.3, 1);
          background: var(--tooltip-bg-dark);
          color: var(--tooltip-text-dark);
          border: 1px solid var(--tooltip-border-dark);
          box-shadow: var(--tooltip-shadow-dark);
        }

        #${TOOLTIP}.${VISIBLE} {
          opacity: 1;
          visibility: visible;
          transform: translateY(0);
        }

        .${PROCESSED}[data-tooltip-time] {
          display: inline-block;
          font-family: ${FONT_MONO};
          cursor: help;
          transition: opacity 0.15s ease;
        }

        .${PROCESSED}[data-tooltip-time]:hover {
          opacity: 0.8;
        }

        @media (prefers-color-scheme: light) {
          #${TOOLTIP} {
            background: var(--tooltip-bg-light);
            color: var(--tooltip-text-light);
            border-color: var(--tooltip-border-light);
            box-shadow: var(--tooltip-shadow-light);
          }
        }
      `);
    },

    createTooltip() {
      if (state.tooltip) return state.tooltip;

      const tooltip = document.createElement("div");
      tooltip.id = CONFIG.IDS.TOOLTIP;
      tooltip.setAttribute("role", "tooltip");
      tooltip.setAttribute("aria-hidden", "true");
      tooltip.setAttribute("translate", "no");
      tooltip.classList.add(CONFIG.CLASSES.NO_TRANSLATE);

      document.body?.appendChild(tooltip);
      state.tooltip = tooltip;
      return tooltip;
    },

    showTooltip(target) {
      const time = target.dataset.tooltipTime;
      if (!time) return;

      const tooltip = this.createTooltip();
      tooltip.textContent = time;
      tooltip.setAttribute("aria-hidden", "false");
      tooltip.classList.add(CONFIG.CLASSES.VISIBLE);

      requestAnimationFrame(() => this.positionTooltip(target, tooltip));
    },

    positionTooltip(target, tooltip) {
      if (!target.isConnected) {
        this.hideTooltip();
        return;
      }

      const rect = target.getBoundingClientRect();
      const { TOOLTIP_OFFSET: offset, EDGE_MARGIN: margin } = CONFIG.SETTINGS;
      const { offsetWidth: w, offsetHeight: h } = tooltip;
      const vw = window.innerWidth;
      const vh = window.innerHeight;

      let left = rect.left + rect.width / 2 - w / 2;
      left = Math.max(margin, Math.min(vw - w - margin, left));

      const spaceAbove = rect.top - offset;
      const spaceBelow = vh - rect.bottom - offset;
      let top;

      if (spaceAbove >= h + margin) {
        top = rect.top - h - offset;
      } else if (spaceBelow >= h + margin) {
        top = rect.bottom + offset;
      } else {
        top =
          spaceAbove > spaceBelow
            ? Math.max(margin, rect.top - h - offset)
            : Math.min(vh - h - margin, rect.bottom + offset);
      }

      tooltip.style.left = `${left}px`;
      tooltip.style.top = `${top}px`;
    },

    hideTooltip() {
      state.tooltip?.classList.remove(CONFIG.CLASSES.VISIBLE);
      state.tooltip?.setAttribute("aria-hidden", "true");
    },
  };

  // ==================== Controller Layer ====================
  // 事件处理、DOM 监听、转换逻辑

  const controller = {
    format(date, type) {
      if (!(date instanceof Date) || isNaN(date.getTime())) return state.getText("INVALID");

      const formatter = state.formatters[type];
      if (formatter) {
        return formatter.format(date).replace(/\//g, "-");
      }

      const pad = (n) => String(n).padStart(2, "0");
      if (type === "date") {
        return `${String(date.getFullYear()).slice(-2)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
      }
      return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
    },

    convertElement(el) {
      if (!(el instanceof Element) || el.classList.contains(CONFIG.CLASSES.PROCESSED)) {
        return;
      }

      const datetime = el.getAttribute("datetime");
      if (!datetime) {
        el.classList.add(CONFIG.CLASSES.PROCESSED);
        return;
      }

      const date = new Date(datetime);
      const dateText = this.format(date, "date");
      const timeText = this.format(date, "time");
      const invalid = state.getText("INVALID");

      if (dateText === invalid || timeText === invalid) {
        el.classList.add(CONFIG.CLASSES.PROCESSED);
        return;
      }

      const span = document.createElement("span");
      span.textContent = dateText;
      span.dataset.tooltipTime = timeText;
      span.classList.add(CONFIG.CLASSES.PROCESSED, CONFIG.CLASSES.NO_TRANSLATE);
      span.setAttribute("translate", "no");

      el.parentNode?.replaceChild(span, el);
    },

    processAll(root = document.body) {
      root
        ?.querySelectorAll(CONFIG.SELECTORS.RELATIVE_TIME)
        .forEach((el) => this.convertElement(el));
    },

    setupTooltipEvents() {
      const selector = CONFIG.SELECTORS.PROCESSED_SPAN;

      document.body.addEventListener("mouseover", (e) => {
        const target = e.target.closest(selector);
        if (target) view.showTooltip(target);
      });

      document.body.addEventListener("mouseout", (e) => {
        const target = e.target.closest(selector);
        if (target && !e.relatedTarget?.closest?.(`#${CONFIG.IDS.TOOLTIP}`)) {
          view.hideTooltip();
        }
      });

      document.body.addEventListener(
        "focusin",
        (e) => {
          const target = e.target.closest(selector);
          if (target) view.showTooltip(target);
        },
        true
      );

      document.body.addEventListener(
        "focusout",
        (e) => {
          const target = e.target.closest(selector);
          if (target) view.hideTooltip();
        },
        true
      );
    },

    setupObserver() {
      const selector = CONFIG.SELECTORS.RELATIVE_TIME;
      const observer = new MutationObserver((mutations) => {
        const elements = new Set();

        for (const { addedNodes } of mutations) {
          for (const node of addedNodes) {
            if (node.nodeType !== Node.ELEMENT_NODE) continue;
            if (node.matches?.(selector)) elements.add(node);
            node.querySelectorAll?.(selector).forEach((el) => elements.add(el));
          }
        }

        elements.forEach((el) => this.convertElement(el));
      });

      observer.observe(document.body, { childList: true, subtree: true });
    },

    init() {
      this.setupTooltipEvents();
      this.setupObserver();
    },
  };

  // ==================== Main ====================

  function init() {
    state.init();
    view.injectStyles();
    controller.processAll();
    controller.init();
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init, { once: true });
  } else {
    init();
  }
})();