GitHub Date Converter

Convert GitHub dates to standard numerical formats, supports custom formats.

2026-04-29 기준 버전입니다. 최신 버전을 확인하세요.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name            GitHub Date Converter
// @name:zh         GitHub 日期转换器
// @namespace       github-date-converter
// @version         0.2.0
// @author          dumeng
// @description     Convert GitHub dates to standard numerical formats, supports custom formats.
// @description:zh  将 GitHub 页面中的日期转换为标准的数字格式,支持自定义格式
// @license         MIT
// @icon            https://github.githubassets.com/favicons/favicon.svg
// @homepage        https://github.com/dumeng-chn/github-date-converter
// @homepageURL     https://github.com/dumeng-chn/github-date-converter
// @source          https://github.com/dumeng-chn/github-date-converter.git
// @supportURL      https://github.com/dumeng-chn/github-date-converter/issues
// @match           https://github.com/*
// @require         https://cdn.jsdelivr.net/npm/[email protected]/dayjs.min.js
// @grant           GM_getValue
// @grant           GM_registerMenuCommand
// @grant           GM_setValue
// @grant           GM_unregisterMenuCommand
// @run-at          document-end
// @compatible      chrome, firefox, edge, safari
// ==/UserScript==

(function (dayjs) {
  'use strict';

  var _GM_getValue = (() => typeof GM_getValue != "undefined" ? GM_getValue : void 0)();
  var _GM_registerMenuCommand = (() => typeof GM_registerMenuCommand != "undefined" ? GM_registerMenuCommand : void 0)();
  var _GM_setValue = (() => typeof GM_setValue != "undefined" ? GM_setValue : void 0)();
  var _GM_unregisterMenuCommand = (() => typeof GM_unregisterMenuCommand != "undefined" ? GM_unregisterMenuCommand : void 0)();
  const PRESETS = [
    { label: "YYYY-MM-DD (2025-12-31)", format: "YYYY-MM-DD" },
    { label: "MM/DD/YYYY (12/31/2025)", format: "MM/DD/YYYY" },
    { label: "DD/MM/YYYY (31/12/2025)", format: "DD/MM/YYYY" },
    { label: "YYYY/MM/DD (2025/12/31)", format: "YYYY/MM/DD" }
  ];
  const FORMAT_KEY = "dateFormat";
  const RELATIVE_KEY = "relativeMode";
  const IGNORE_FILE_LIST_KEY = "ignoreFileList";
  const LANG_KEY = "language";
  const DEFAULT_FORMAT = "YYYY-MM-DD";
  function getLang() {
    const defaultLang = navigator.language.startsWith("zh") ? "zh" : "en";
    return _GM_getValue(LANG_KEY, defaultLang);
  }
  const I18N = {
    en: {
      customFormatMenu: "✏️ Custom Format",
      customFormatPrompt: "Enter date format (placeholders: YYYY, MM, DD)\nExample: YYYY-MM-DD",
      relativeModeNone: "Do not convert relative time (Default)",
      relativeMode7Days: "Only convert relative time > 7 days",
      relativeModeAll: "Convert all relative time",
      ignoreFileListMenu: "📁 Ignore repository file list dates",
      settingsChangedReload: "Settings changed. Some converted elements require a page reload to restore. Reload now?",
      settingsChangedSimple: "Settings changed. Reload page now?",
      langToggleMenu: "🌐 Language / 语言: English"
    },
    zh: {
      customFormatMenu: "✏️ 自定义格式",
      customFormatPrompt: "请输入日期格式(可用占位符:YYYY 年、MM 月、DD 日)\n示例:YYYY年MM月DD日",
      relativeModeNone: "不转换相对时间(默认)",
      relativeMode7Days: "仅转换大于 7 天的相对时间",
      relativeModeAll: "转换所有相对时间",
      ignoreFileListMenu: "📁 忽略仓库文件列表中的日期",
      settingsChangedReload: "设置已更改。部分已转换的元素需要刷新页面才能还原,是否立即刷新?",
      settingsChangedSimple: "设置已更改。是否立即刷新页面?",
      langToggleMenu: "🌐 Language / 语言: 简体中文"
    }
  };
  function getRelativeMode() {
    const val = _GM_getValue(RELATIVE_KEY, "none");
    return val;
  }
  function formatDateObj(date, format) {
    return dayjs(date).format(format);
  }
  function formatDate(isoDatetime, format) {
    const d = dayjs(isoDatetime);
    if (!d.isValid()) return "";
    return d.format(format);
  }
  const MONTH_PATTERN = /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/;
  const TEXT_DATE_PATTERN = /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}(?:,\s+\d{4})?\b/g;
  function processCustomElement(el) {
    const datetime = el.getAttribute("datetime");
    if (!datetime) return;
    const shadow = el.shadowRoot;
    const currentText = (shadow ? shadow.textContent : el.textContent)?.trim() || "";
    const format = _GM_getValue(FORMAT_KEY, DEFAULT_FORMAT);
    const formatted = formatDate(datetime, format);
    if (!formatted) return;
    if (currentText === formatted) return;
    const relativeMode = getRelativeMode();
    const ignoreFileList = _GM_getValue(IGNORE_FILE_LIST_KEY, true);
    if (ignoreFileList && el.closest(".react-directory-commit-age, td.age")) {
      return;
    }
    const isAbsolute = MONTH_PATTERN.test(currentText);
    if (!isAbsolute) {
      if (relativeMode === "none") {
        return;
      } else if (relativeMode === "7days") {
        const ageInDays = Math.abs(Date.now() - new Date(datetime).getTime()) / (1e3 * 60 * 60 * 24);
        if (ageInDays <= 7) return;
      }
    }
    if (shadow) {
      shadow.textContent = formatted;
    } else {
      el.textContent = formatted;
    }
  }
  function processTextElement(el) {
    if (el.children.length > 0) return;
    const originalText = el.getAttribute("data-ghd-original") || el.textContent || "";
    if (!originalText || !originalText.match(TEXT_DATE_PATTERN)) return;
    if (!el.hasAttribute("data-ghd-original")) {
      el.setAttribute("data-ghd-original", originalText);
    }
    const format = _GM_getValue(FORMAT_KEY, DEFAULT_FORMAT);
    const newText = originalText.replace(TEXT_DATE_PATTERN, (match) => {
      let dateStr = match;
      if (!/\d{4}/.test(match)) {
        dateStr = `${match}, ${( new Date()).getFullYear()}`;
      }
      const date = new Date(dateStr);
      if (isNaN(date.getTime())) return match;
      return formatDateObj(date, format);
    });
    if (newText !== el.textContent) {
      el.textContent = newText;
    }
  }
  const TIME_SELECTORS = ["relative-time", "time-ago", "local-time"].join(",");
  const TEXT_SELECTORS = [
    '[data-testid="commit-group-title"]',
".gh-header-meta .Label--secondary",
".release-entry .Label--secondary"
].join(",");
  function processAll() {
    document.querySelectorAll(TIME_SELECTORS).forEach(processCustomElement);
    document.querySelectorAll(TEXT_SELECTORS).forEach(processTextElement);
  }
  function reprocessAll() {
    processAll();
  }
  function startObserver() {
    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (mutation.type === "childList") {
          if (mutation.target.nodeType === Node.ELEMENT_NODE) {
            const targetEl = mutation.target;
            if (targetEl.matches(TIME_SELECTORS)) processCustomElement(targetEl);
            if (targetEl.matches(TEXT_SELECTORS)) processTextElement(targetEl);
          }
          mutation.addedNodes.forEach((node) => {
            if (node.nodeType !== Node.ELEMENT_NODE) return;
            const el = node;
            if (el.matches(TIME_SELECTORS)) processCustomElement(el);
            if (el.matches(TEXT_SELECTORS)) processTextElement(el);
            el.querySelectorAll?.(TIME_SELECTORS).forEach(processCustomElement);
            el.querySelectorAll?.(TEXT_SELECTORS).forEach(processTextElement);
          });
        } else if (mutation.type === "attributes" && mutation.attributeName === "title") {
          if (mutation.target.nodeType === Node.ELEMENT_NODE) {
            const targetEl = mutation.target;
            if (targetEl.matches(TIME_SELECTORS)) processCustomElement(targetEl);
          }
        }
      }
    });
    observer.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ["title"]
    });
  }
  let menuIds = [];
  function registerMenuCommands() {
    menuIds.forEach((id) => _GM_unregisterMenuCommand(id));
    menuIds = [];
    const currentFormat = _GM_getValue(FORMAT_KEY, DEFAULT_FORMAT);
    const isCustomFormat = !PRESETS.some((p) => p.format === currentFormat);
    const lang = getLang();
    const t = I18N[lang];
    let separatorCount = 0;
    const addSeparator = () => {
      const spaces = " ".repeat(separatorCount++);
      menuIds.push(_GM_registerMenuCommand("┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈" + spaces, () => {
      }));
    };
    menuIds.push(
      _GM_registerMenuCommand(t.langToggleMenu, () => {
        _GM_setValue(LANG_KEY, lang === "en" ? "zh" : "en");
        registerMenuCommands();
      })
    );
    addSeparator();
    PRESETS.forEach(({ label, format }) => {
      const check = format === currentFormat ? "✅" : "⬛";
      const id = _GM_registerMenuCommand(`${check} 📅 ${label}`, () => {
        _GM_setValue(FORMAT_KEY, format);
        reprocessAll();
        registerMenuCommands();
      });
      menuIds.push(id);
    });
    const customCheck = isCustomFormat ? "✅" : "⬛";
    const customLabel = isCustomFormat ? `${t.customFormatMenu.replace("...", "")} (${currentFormat})` : t.customFormatMenu;
    menuIds.push(
      _GM_registerMenuCommand(`${customCheck} ${customLabel}`, () => {
        const custom = prompt(t.customFormatPrompt, currentFormat);
        if (custom && custom.trim()) {
          _GM_setValue(FORMAT_KEY, custom.trim());
          reprocessAll();
          registerMenuCommands();
        }
      })
    );
    addSeparator();
    const relativeMode = getRelativeMode();
    const relativeOptions = [
      { label: t.relativeModeNone, value: "none" },
      { label: t.relativeMode7Days, value: "7days" },
      { label: t.relativeModeAll, value: "all" }
    ];
    relativeOptions.forEach(({ label, value }) => {
      const check = value === relativeMode ? "✅" : "⬛";
      const id = _GM_registerMenuCommand(`${check} ⏳ ${label}`, () => {
        _GM_setValue(RELATIVE_KEY, value);
        if (confirm(t.settingsChangedReload)) {
          location.reload();
        } else {
          reprocessAll();
          registerMenuCommands();
        }
      });
      menuIds.push(id);
    });
    addSeparator();
    const ignoreFileList = _GM_getValue(IGNORE_FILE_LIST_KEY, true);
    const checkIgnore = ignoreFileList ? "✅" : "⬛";
    menuIds.push(
      _GM_registerMenuCommand(`${checkIgnore} ${t.ignoreFileListMenu}`, () => {
        _GM_setValue(IGNORE_FILE_LIST_KEY, !ignoreFileList);
        if (confirm(t.settingsChangedSimple)) {
          location.reload();
        } else {
          reprocessAll();
          registerMenuCommands();
        }
      })
    );
  }
  processAll();
  startObserver();
  registerMenuCommands();

})(dayjs);