// ==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 }
);
}
})();