// ExtendedConfirmedChecker.js
// Adds indicators next to usernames on talk pages showing extended confirmed status
// License: copyleft
$(function() {
'use strict';
// Only run on talk pages
const namespace = mw.config.get('wgNamespaceNumber');
console.log('Current namespace:', namespace);
if (namespace % 2 !== 1) {
console.log('Not a talk page, exiting');
return;
}
console.log('Running on talk page');
// Cache handling
const CACHE_KEY = 'ec-status-cache';
const CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
function loadCache() {
try {
const cache = localStorage.getItem(CACHE_KEY);
if (cache) {
const { data, timestamp } = JSON.parse(cache);
if (Date.now() - timestamp < CACHE_EXPIRY) {
return new Map(Object.entries(data));
}
}
} catch (e) {
console.error('Error loading cache:', e);
}
return new Map();
}
function saveCache(cache) {
try {
const cacheData = {
data: Object.fromEntries(cache),
timestamp: Date.now()
};
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
} catch (e) {
console.error('Error saving cache:', e);
}
}
// Define advanced groups that imply extended confirmed status
const ADVANCED_GROUPS = new Set([
'sysop', // Administrators
'bot', // Bots
'checkuser', // CheckUsers
'oversight', // Oversighters
'founder', // Founders
'steward', // Stewards
'staff', // Wikimedia staff
'bureaucrat', // Bureaucrats
'extendedconfirmed' // Explicitly extended confirmed
]);
const processedUsers = new Set();
const userGroups = loadCache();
// Check if a URL path is a subpage
function isSubpage(path) {
// First decode the URL to handle any encoded characters
const decodedPath = decodeURIComponent(path);
// Remove any URL parameters or fragments
const cleanPath = decodedPath.split(/[?#]/)[0];
// Check if there's a slash after "User:"
return /User:[^/]+\//.test(cleanPath);
}
// Find all user links in signatures
function findUserLinks() {
// Find both regular user links and redlinks, excluding talk pages and user subpages
const links = $('#content a').filter(function() {
const href = $(this).attr('href');
// Basic check for user page links
if (!href || (!href.startsWith('/wiki/User:') && !href.startsWith('/w/index.php?title=User:'))) {
return false;
}
// Exclude talk pages
if (href.includes('talk')) {
return false;
}
// Exclude already processed links
if ($(this).attr('data-ec-checked')) {
return false;
}
// Check for subpages
if (href.startsWith('/wiki/')) {
if (isSubpage(href)) {
return false;
}
} else {
// For redlinks, check the title parameter
const url = new URL(href, window.___location.origin);
const title = url.searchParams.get('title');
if (title && isSubpage(title)) {
return false;
}
}
return true;
});
console.log('Found user links:', links.length);
links.each((_, link) => {
const username = getUsernameFromLink(link);
console.log('User link:', $(link).text(), '→', username, $(link).attr('href'));
});
return links;
}
// Extract username from link
function getUsernameFromLink(link) {
const href = $(link).attr('href');
let match;
// Handle both regular wiki links and redlinks
if (href.startsWith('/wiki/')) {
match = decodeURIComponent(href).match(/User:([^/?&#]+)/);
} else {
// For redlinks, check the title parameter
const url = new URL(href, window.___location.origin);
const title = url.searchParams.get('title');
if (title) {
match = decodeURIComponent(title).match(/User:([^/?&#]+)/);
}
}
if (match) {
// Remove any subpage part if it somehow got through
const username = match[1].split('/')[0];
return username.replace(/_/g, ' ');
}
return null;
}
// Check if user has any advanced group
function hasAdvancedGroup(groups) {
return groups.some(group => ADVANCED_GROUPS.has(group));
}
// Batch process users to reduce API calls
async function processUserBatch(users) {
if (users.length === 0) return;
const userList = users.join('|');
console.log('Fetching groups for users:', userList);
const maxRetries = 3;
let retryCount = 0;
let delay = 1000; // Start with 1 second delay
while (retryCount < maxRetries) {
try {
const response = await $.ajax({
url: mw.util.wikiScript('api'),
data: {
action: 'query',
format: 'json',
list: 'users',
usprop: 'groups|blockinfo',
ususers: userList,
formatversion: '2'
},
dataType: 'json'
});
console.log('API response:', response);
if (response.error && response.error.code === 'ratelimited') {
console.log('Rate limited, waiting before retry...');
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
retryCount++;
continue;
}
if (response.query && response.query.users) {
response.query.users.forEach(user => {
let status;
if (user.missing) {
status = 'missing';
} else if (user.blockedby) {
status = 'blocked';
} else {
const groups = user.groups || [];
// Check if user has any advanced group
status = hasAdvancedGroup(groups) ? 'extended' : 'normal';
}
userGroups.set(user.name, status);
});
// Save updated cache
saveCache(userGroups);
}
break; // Success, exit retry loop
} catch (error) {
console.error('Error fetching user groups:', error);
if (retryCount >= maxRetries - 1) {
// Mark all users in batch as error if we've exhausted retries
users.forEach(username => userGroups.set(username, 'error'));
saveCache(userGroups);
} else {
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
retryCount++;
}
}
}
}
// Add status indicator next to username
function addStatusIndicator(link, status) {
// Remove any existing indicators next to this link
$(link).siblings('.ec-status-indicator').remove();
let symbol, color, title;
switch(status) {
case 'extended':
symbol = '✔';
color = '#00a000';
title = 'Extended confirmed user';
break;
case 'error':
symbol = '?';
color = '#666666';
title = 'Error checking status';
break;
case 'blocked':
symbol = '🚫';
color = '#cc0000';
title = 'Blocked user';
break;
case 'missing':
symbol = '!';
color = '#666666';
title = 'User not found';
break;
default:
symbol = '✘';
color = '#cc0000';
title = 'Not extended confirmed';
}
const indicator = $('<span>')
.addClass('ec-status-indicator')
.css({
'margin-left': '4px',
'font-size': '0.85em',
'color': color,
'cursor': 'help'
})
.attr('title', title)
.text(symbol);
$(link).after(indicator);
$(link).attr('data-ec-checked', 'true');
}
// Main processing function
async function processPage() {
console.log('Processing page...');
const userLinks = findUserLinks();
const batchSize = 50;
const users = [];
userLinks.each((_, link) => {
const username = getUsernameFromLink(link);
if (username && !processedUsers.has(username)) {
users.push(username);
processedUsers.add(username);
}
});
// Process users in batches
for (let i = 0; i < users.length; i += batchSize) {
const batch = users.slice(i, i + batchSize);
await processUserBatch(batch);
}
// Add indicators
userLinks.each((_, link) => {
const username = getUsernameFromLink(link);
const isExtendedConfirmed = userGroups.get(username);
addStatusIndicator(link, isExtendedConfirmed);
});
}
// Run on page load and when new content is added
processPage();
mw.hook('wikipage.content').add(processPage);
});