// ==UserScript==
// @name GitHub Repo Age
// @description Displays repository creation date/time/age.
// @icon https://github.githubassets.com/favicons/favicon-dark.svg
// @version 1.3
// @author afkarxyz
// @namespace https://github.com/afkarxyz/userscripts/
// @supportURL https://github.com/afkarxyz/userscripts/issues
// @license MIT
// @match https://github.com/*/*
// @grant GM_xmlhttpRequest
// @connect api.codetabs.com
// @connect api.github.com
// ==/UserScript==
(function () {
'use strict';
const githubApiBase = 'https://api.github.com/repos/';
const fallbackApiBase = 'https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/repos/';
const CACHE_KEY_PREFIX = 'github_repo_created_';
const selectors = {
desktop: [
'.BorderGrid-cell .hide-sm.hide-md .f4.my-3',
'.BorderGrid-cell'
],
mobile: [
'.d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .f4.mb-3.color-fg-muted',
'.d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5 .d-flex.gap-2.mt-n3.mb-3.flex-wrap',
'.d-block.d-md-none.mb-2.px-3.px-md-4.px-lg-5'
]
};
let currentRepoPath = '';
function formatDate(isoDateStr) {
const createdDate = new Date(isoDateStr);
const now = new Date();
const diffTime = Math.abs(now - createdDate);
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
const diffHours = Math.floor((diffTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const diffMinutes = Math.floor((diffTime % (1000 * 60 * 60)) / (1000 * 60));
const diffMonths = Math.floor(diffDays / 30.44);
const diffYears = Math.floor(diffMonths / 12);
const remainingMonths = diffMonths % 12;
const remainingDays = Math.floor(diffDays % 30.44);
const datePart = createdDate.toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
const timePart = createdDate.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
let ageText = '';
if (diffYears > 0) {
ageText = `${diffYears} year${diffYears !== 1 ? 's' : ''}`;
if (remainingMonths > 0) {
ageText += ` ${remainingMonths} month${remainingMonths !== 1 ? 's' : ''}`;
}
} else if (diffMonths > 0) {
ageText = `${diffMonths} month${diffMonths !== 1 ? 's' : ''}`;
if (remainingDays > 0) {
ageText += ` ${remainingDays} day${remainingDays !== 1 ? 's' : ''}`;
}
} else if (diffDays > 0) {
ageText = `${diffDays} day${diffDays !== 1 ? 's' : ''}`;
if (diffHours > 0) {
ageText += ` ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
}
} else if (diffHours > 0) {
ageText = `${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
if (diffMinutes > 0) {
ageText += ` ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
}
} else {
ageText = `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
}
return `${datePart} - ${timePart} (${ageText} ago)`;
}
const cache = {
getKey: function(user, repo) {
return `${CACHE_KEY_PREFIX}${user}_${repo}`;
},
get: function(user, repo) {
try {
const key = this.getKey(user, repo);
const cachedValue = localStorage.getItem(key);
if (!cachedValue) return null;
return JSON.parse(cachedValue);
} catch (err) {
return null;
}
},
set: function(user, repo, value) {
try {
const key = this.getKey(user, repo);
localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
}
}
};
async function fetchFromGitHubApi(user, repo) {
const apiUrl = `${githubApiBase}${user}/${repo}`;
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: apiUrl,
headers: {
'Accept': 'application/vnd.github.v3+json'
},
onload: function(response) {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
const createdAt = data.created_at;
if (createdAt) {
resolve({ success: true, data: createdAt });
} else {
resolve({ success: false, error: 'Missing creation date' });
}
} catch (e) {
resolve({ success: false, error: 'JSON parse error' });
}
} else {
resolve({
success: false,
error: `Status ${response.status}`,
useProxy: response.status === 403 || response.status === 429
});
}
},
onerror: function() {
resolve({ success: false, error: 'Network error', useProxy: true });
},
ontimeout: function() {
resolve({ success: false, error: 'Timeout', useProxy: true });
}
});
});
}
async function fetchFromProxyApi(user, repo) {
const apiUrl = `${fallbackApiBase}${user}/${repo}`;
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: apiUrl,
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
try {
const data = JSON.parse(response.responseText);
const createdAt = data.created_at;
if (createdAt) {
resolve({ success: true, data: createdAt });
} else {
resolve({ success: false, error: 'Missing creation date' });
}
} catch (e) {
resolve({ success: false, error: 'JSON parse error' });
}
} else {
resolve({ success: false, error: `Status ${response.status}` });
}
},
onerror: function() {
resolve({ success: false, error: 'Network error' });
},
ontimeout: function() {
resolve({ success: false, error: 'Timeout' });
}
});
});
}
async function getRepoCreationDate(user, repo) {
const cachedDate = cache.get(user, repo);
if (cachedDate) {
return cachedDate;
}
const directResult = await fetchFromGitHubApi(user, repo);
if (directResult.success) {
cache.set(user, repo, directResult.data);
return directResult.data;
}
if (directResult.useProxy) {
console.log('GitHub Repo Age: Use Proxy');
const proxyResult = await fetchFromProxyApi(user, repo);
if (proxyResult.success) {
cache.set(user, repo, proxyResult.data);
return proxyResult.data;
}
}
return null;
}
async function insertCreatedDate() {
const match = window.location.pathname.match(/^\/([^\/]+)\/([^\/]+)/);
if (!match) return false;
const [_, user, repo] = match;
const repoPath = `${user}/${repo}`;
currentRepoPath = repoPath;
const createdAt = await getRepoCreationDate(user, repo);
if (!createdAt) return false;
const formattedDate = formatDate(createdAt);
let insertedCount = 0;
document.querySelectorAll('.repo-created-date').forEach(el => el.remove());
for (const [view, selectorsList] of Object.entries(selectors)) {
for (const selector of selectorsList) {
const element = document.querySelector(selector);
if (element && !element.querySelector(`.repo-created-${view}`)) {
insertDateElement(element, formattedDate, view);
insertedCount++;
break;
}
}
}
return insertedCount > 0;
}
function insertDateElement(targetElement, formattedDate, view) {
const p = document.createElement('p');
p.className = `f6 color-fg-muted repo-created-date repo-created-${view}`;
p.style.marginTop = '4px';
p.style.marginBottom = '8px';
p.innerHTML = `<strong>Created</strong> ${formattedDate}`;
if (view === 'mobile') {
const flexWrap = targetElement.querySelector('.flex-wrap');
if (flexWrap) {
flexWrap.parentNode.insertBefore(p, flexWrap.nextSibling);
return;
}
const dFlex = targetElement.querySelector('.d-flex');
if (dFlex) {
dFlex.parentNode.insertBefore(p, dFlex.nextSibling);
return;
}
}
targetElement.insertBefore(p, targetElement.firstChild);
}
function checkAndInsertWithRetry(retryCount = 0, maxRetries = 5) {
insertCreatedDate().then(inserted => {
if (!inserted && retryCount < maxRetries) {
const delay = Math.pow(2, retryCount) * 500;
setTimeout(() => checkAndInsertWithRetry(retryCount + 1, maxRetries), delay);
}
});
}
function checkForRepoChange() {
const match = window.location.pathname.match(/^\/([^\/]+)\/([^\/]+)/);
if (!match) return;
const [_, user, repo] = match;
const repoPath = `${user}/${repo}`;
if (repoPath !== currentRepoPath) {
checkAndInsertWithRetry();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => checkAndInsertWithRetry());
} else {
checkAndInsertWithRetry();
}
const originalPushState = history.pushState;
history.pushState = function() {
originalPushState.apply(this, arguments);
setTimeout(checkForRepoChange, 100);
};
const originalReplaceState = history.replaceState;
history.replaceState = function() {
originalReplaceState.apply(this, arguments);
setTimeout(checkForRepoChange, 100);
};
window.addEventListener('popstate', () => {
setTimeout(checkForRepoChange, 100);
});
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' &&
(mutation.target.id === 'js-repo-pjax-container' ||
mutation.target.id === 'repository-container-header')) {
setTimeout(checkForRepoChange, 100);
break;
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
})();