/**
* AdminNewsletterTools
* Administrators' newsletter related updates for a specific month
* @author DreamRimmer ([[en:User:DreanRimmer]])
*
* <nowiki>
*/
$(document).ready(function() {
// Simple cache for API results
var apiCache = {};
var CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
function getCacheKey(api, params) {
return JSON.stringify({api: api.toString(), params: params});
}
function getCachedResult(key) {
var cached = apiCache[key];
if (cached && (Date.now() - cached.timestamp < CACHE_DURATION)) {
return cached.data;
}
return null;
}
function setCachedResult(key, data) {
apiCache[key] = {
data: data,
timestamp: Date.now()
};
}
function initializeAdminNewsTools() {
$('#mw-content-text > p').remove();
$('#firstHeading').text('Administrators\' newsletter tools');
var monthSelect = new OO.ui.DropdownWidget({
menu: {
items: [
new OO.ui.MenuOptionWidget({ data: '1', label: 'January' }),
new OO.ui.MenuOptionWidget({ data: '2', label: 'February' }),
new OO.ui.MenuOptionWidget({ data: '3', label: 'March' }),
new OO.ui.MenuOptionWidget({ data: '4', label: 'April' }),
new OO.ui.MenuOptionWidget({ data: '5', label: 'May' }),
new OO.ui.MenuOptionWidget({ data: '6', label: 'June' }),
new OO.ui.MenuOptionWidget({ data: '7', label: 'July' }),
new OO.ui.MenuOptionWidget({ data: '8', label: 'August' }),
new OO.ui.MenuOptionWidget({ data: '9', label: 'September' }),
new OO.ui.MenuOptionWidget({ data: '10', label: 'October' }),
new OO.ui.MenuOptionWidget({ data: '11', label: 'November' }),
new OO.ui.MenuOptionWidget({ data: '12', label: 'December' })
]
}
}),
yearInput = new OO.ui.NumberInputWidget({
value: new Date().getFullYear(),
min: 2005,
max: new Date().getFullYear() + 1,
step: 1
}),
sectionSelect = new OO.ui.DropdownWidget({
menu: {
items: [
new OO.ui.MenuOptionWidget({ data: 'all', label: 'All sections' }),
new OO.ui.MenuOptionWidget({ data: 'rights', label: 'Rights changes' }),
new OO.ui.MenuOptionWidget({ data: 'tech', label: 'Tech News' }),
new OO.ui.MenuOptionWidget({ data: 'centralized', label: 'CENT' }),
new OO.ui.MenuOptionWidget({ data: 'arbcom', label: 'ArbCom updates' }),
new OO.ui.MenuOptionWidget({ data: 'rfc', label: 'Recent RfCs' }),
new OO.ui.MenuOptionWidget({ data: 'misc', label: 'Miscellaneous' })
]
}
}),
checkButton = new OO.ui.ButtonWidget({
label: 'Check',
flags: ['primary', 'progressive']
}),
resultsContainer = $("<div>").hide();
var currentDate = new Date();
var currentMonth = String(currentDate.getMonth() + 1);
var monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
var quickLinks = $('<p>').css({
'display': 'flex',
'justify-content': 'space-between',
'align-items': 'center',
'margin': '10px 0',
'padding': '10px 15px',
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'border-radius': '3px'
}).html(`
<a href="https://en.wikipedia.org/wiki/Wikipedia:Administrators%27_newsletter/${currentDate.getFullYear()}/${currentDate.getMonth() + 1}" target="_blank">Help with ${monthNames[currentDate.getMonth()]} ${currentDate.getFullYear()} issue</a>
<span>•</span>
<a href="https://en.wikipedia.org/wiki/Wikipedia:Administrators%27_newsletter/Archive" target="_blank">Archive</a>
<span>•</span>
<a href="https://en.wikipedia.org/wiki/Wikipedia:Administrators%27_newsletter/Subscribe" target="_blank">Subscribe</a>
<span>•</span>
<a href="https://en.wikipedia.org/wiki/Wikipedia:Administrators%27_newsletter/Write" target="_blank">Write</a>
<span>•</span>
<a href="https://en.wikipedia.org/wiki/Wikipedia_talk:Administrators%27_newsletter" target="_blank">Discuss</a>
`);
monthSelect.getMenu().selectItemByData(currentMonth);
sectionSelect.getMenu().selectItemByData('all');
yearInput.$element.css('width', '80px');
var labels = {
monthLabel: $('<p>').text('Month:').css('font-weight', 'bold'),
yearLabel: $('<p>').text('Year:').css('font-weight', 'bold'),
sectionLabel: $('<p>').text('Section:').css('font-weight', 'bold'),
description: $('<p>').html('The <a href="https://en.wikipedia.org/wiki/Wikipedia:Administrators%27_newsletter" target="_blank">administrators\' newsletter</a> is a monthly update containing relevant information on administrative issues aimed at <a href="https://en.wikipedia.org/wiki/WP:ADMIN" target="_blank">administrators</a>. It is intended to help keep administrators up to date with changes to Wikipedia that they may otherwise miss. You can view various Administrators\' newsletter related updates for a specific month here. Please choose a month and year to get results.')
};
$('#mw-content-text').append(
labels.description,
quickLinks,
$('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '15px',
'margin-bottom': '20px',
'border-radius': '3px'
}).append(
$('<h3>').text('Select Month, Year and Section'),
$('<div>').css({
'display': 'flex',
'gap': '15px',
'align-items': 'end',
'flex-wrap': 'wrap'
}).append(
$('<div>').append(labels.monthLabel, monthSelect.$element),
$('<div>').append(labels.yearLabel, yearInput.$element),
$('<div>').append(labels.sectionLabel, sectionSelect.$element),
$('<div>').append(checkButton.$element)
)
),
resultsContainer
);
function formatDate(date) {
const options = { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' };
return date.toLocaleDateString('en-GB', options).replace(',', '') + ' UTC';
}
function formatDateFromTimestamp(timestamp) {
const date = new Date(timestamp);
const options = { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' };
return date.toLocaleDateString('en-GB', options).replace(',', '') + ' UTC';
}
function parseWikilinks(text, isMeta = false) {
if (!text) return '';
const baseWikiUrl = isMeta ? 'https://meta.wikimedia.org' : 'https://en.wikipedia.org';
try {
text = text.replace(/\[([^\s\]]+)\s+([^\]]+)\]/g, function(match, url, displayText) {
try {
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) {
return `<a href="${url}" target="_blank">${displayText}</a>`;
}
return match;
} catch (e) {
return match;
}
});
text = text.replace(/\[([^\s\]]+)\]/g, function(match, url) {
try {
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) {
return `<a href="${url}" target="_blank">${url}</a>`;
}
return match;
} catch (e) {
return match;
}
});
text = text.replace(/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g, function(match, link, pipe, display) {
try {
const displayText = display || link;
let url;
if (link.startsWith('Special:')) {
if (link.startsWith('Special:Diff/')) {
const diffPart = link.replace('Special:Diff/', '');
const hashIndex = diffPart.indexOf('#');
const diffId = hashIndex !== -1 ? diffPart.substring(0, hashIndex) : diffPart;
const fragment = hashIndex !== -1 ? diffPart.substring(hashIndex) : '';
if (/^\d+$/.test(diffId)) {
url = `${baseWikiUrl}/w/index.php?diff=${diffId}${fragment}`;
} else {
return match;
}
} else if (link.startsWith('Special:Contributions/')) {
const user = link.replace('Special:Contributions/', '');
if (user.trim()) {
url = `${baseWikiUrl}/wiki/Special:Contributions/${encodeURIComponent(user)}`;
} else {
return match;
}
} else {
url = `${baseWikiUrl}/wiki/${encodeURIComponent(link)}`;
}
} else if (link.startsWith('User:') || link.startsWith('User talk:')) {
url = `${baseWikiUrl}/wiki/${encodeURIComponent(link)}`;
} else if (link.startsWith('Wikipedia:')) {
url = `${baseWikiUrl}/wiki/${encodeURIComponent(link)}`;
} else {
url = `${baseWikiUrl}/wiki/${encodeURIComponent(link)}`;
}
return `<a href="${url}" target="_blank">${displayText}</a>`;
} catch (e) {
return match;
}
});
return text;
} catch (e) {
return text;
}
}
function getWeeksInMonth(year, month) {
const weeks = [];
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0);
let currentDate = new Date(startDate);
while (currentDate <= endDate) {
const weekNumber = getISOWeek(currentDate);
if (!weeks.includes(weekNumber)) {
weeks.push(weekNumber);
}
currentDate.setDate(currentDate.getDate() + 1);
}
return weeks;
}
function getISOWeek(date) {
const target = new Date(date.valueOf());
const dayNr = (date.getDay() + 6) % 7;
target.setDate(target.getDate() - dayNr + 3);
const jan4 = new Date(target.getFullYear(), 0, 4);
const dayDiff = (target - jan4) / 86400000;
return 1 + Math.ceil(dayDiff / 7);
}
function processLogEntries(entries, targetGroups) {
const granted = [];
const removed = [];
entries.forEach(entry => {
if (!entry.title) return;
const timestamp = entry.timestamp;
const userTarget = entry.title;
const actor = entry.user;
let oldGroups = new Set();
let newGroups = new Set();
if (entry.params) {
if (entry.params.oldgroups) {
oldGroups = new Set(entry.params.oldgroups);
}
if (entry.params.newgroups) {
newGroups = new Set(entry.params.newgroups);
}
if (entry.params['4::oldgroups']) {
oldGroups = new Set(entry.params['4::oldgroups']);
}
if (entry.params['5::newgroups']) {
newGroups = new Set(entry.params['5::newgroups']);
}
}
if (entry.comment) {
const commentMatch = entry.comment.match(/changed group membership for (.+?) from (.+?) to (.+)/);
if (commentMatch) {
const oldGroupsStr = commentMatch[2];
const newGroupsStr = commentMatch[3];
if (oldGroupsStr !== '(none)') {
oldGroups = new Set(oldGroupsStr.split(', '));
}
if (newGroupsStr !== '(none)') {
newGroups = new Set(newGroupsStr.split(', '));
}
}
}
targetGroups.forEach(grp => {
if (newGroups.has(grp) && !oldGroups.has(grp)) {
granted.push([timestamp, userTarget, grp, actor, entry.logid, entry.comment || '']);
}
if (oldGroups.has(grp) && !newGroups.has(grp)) {
removed.push([timestamp, userTarget, grp, actor, entry.logid, entry.comment || '']);
}
});
});
return { granted: granted.sort(), removed: removed.sort() };
}
function createRightsTable(title, data, isMeta = false) {
if (!data.length) return '';
let html = `<h4>${title}</h4><table class="wikitable sortable"><thead><tr><th>Timestamp</th><th>User</th><th>Right</th><th>By</th><th>Comment</th></tr></thead><tbody>`;
data.forEach(([ts, tgt, grp, by, logid, comment]) => {
const sign = title.includes('granted') ? '+' : '-';
const baseUrl = isMeta ? 'https://meta.wikimedia.org' : 'https://en.wikipedia.org';
const logUrl = `${baseUrl}/w/index.php?title=Special:Log&logid=${logid}`;
const timestampLink = `<a href="${logUrl}" target="_blank">${formatDateFromTimestamp(ts)}</a>`;
let userLink, byLink;
if (isMeta) {
const cleanUsername = tgt.replace('@enwiki', '');
const finalUsername = cleanUsername.startsWith('User:') ? cleanUsername.substring(5) : cleanUsername;
userLink = `<a href="https://en.wikipedia.org/wiki/User:${encodeURIComponent(finalUsername)}" target="_blank">${tgt}</a>`;
const finalByUsername = by.startsWith('User:') ? by.substring(5) : by;
byLink = `<a href="https://meta.wikimedia.org/wiki/User:${encodeURIComponent(finalByUsername)}" target="_blank">${by}</a>`;
} else {
const finalTgtUsername = tgt.startsWith('User:') ? tgt.substring(5) : tgt;
userLink = `<a href="https://en.wikipedia.org/wiki/User:${encodeURIComponent(finalTgtUsername)}" target="_blank">${tgt}</a>`;
const finalByUsername = by.startsWith('User:') ? by.substring(5) : by;
byLink = `<a href="https://en.wikipedia.org/wiki/User:${encodeURIComponent(finalByUsername)}" target="_blank">${by}</a>`;
}
html += `<tr><td>${timestampLink}</td><td>${userLink}</td><td>${sign}${grp}</td><td>${byLink}</td><td>${parseWikilinks(comment, isMeta)}</td></tr>`;
});
html += '</tbody></table>';
return html;
}
function createEditTable(title, data, showDiff = false) {
if (!data.length) return `<p>No ${title.toLowerCase()} found for this period.</p>`;
const headerLabel = title.includes('ArbCom topics') ? 'Topic' : 'Comment';
const userLabel = title.includes('ArbCom topics') ? 'Arbitrator/Clerk' : 'User';
const dateLabel = title.includes('ArbCom topics') ? 'Timestamp' : 'Date';
let html = `<h4>${title}</h4><table class="wikitable sortable"><thead><tr><th>${dateLabel}</th><th>${userLabel}</th><th>${headerLabel}</th></tr></thead><tbody>`;
data.forEach(item => {
const timestamp = showDiff && item.revid ?
`<a href="https://en.wikipedia.org/w/index.php?diff=${item.revid}" target="_blank">${formatDateFromTimestamp(item.timestamp)}</a>` :
formatDateFromTimestamp(item.timestamp);
const finalUsername = item.user.startsWith('User:') ? item.user.substring(5) : item.user;
const userLink = `<a href="https://en.wikipedia.org/wiki/User:${encodeURIComponent(finalUsername)}" target="_blank">${item.user}</a>`;
let cellContent = parseWikilinks(item.comment || '', false);
if (title.includes('ArbCom topics') && item.comment && item.revid) {
const sectionMatch = item.comment.match(/\/\*\s*([^*]+?)\s*\*\//);
if (sectionMatch) {
const sectionName = sectionMatch[1].trim();
const encodedSection = encodeURIComponent(sectionName.replace(/ /g, '_'));
cellContent = `<a href="https://en.wikipedia.org/w/index.php?diff=${item.revid}#${encodedSection}" target="_blank">${sectionName}</a>`;
}
}
html += `<tr><td>${timestamp}</td><td>${userLink}</td><td>${cellContent}</td></tr>`;
});
html += '</tbody></table>';
return html;
}
function createCollapsibleSection(title, content, extraLinks = '') {
const sectionId = title.replace(/\s+/g, '').toLowerCase();
return `
<div style="border: 1px solid #ddd; margin-bottom: 15px; border-radius: 3px;">
<div style="background: #f8f9fa; padding: 10px; border-bottom: 1px solid #ddd; cursor: pointer;" onclick="toggleSection('${sectionId}')">
<h3 style="margin: 0; display: inline-block;">${title}</h3>
<span id="${sectionId}-toggle" style="float: right;">[hide]</span>
${extraLinks}
</div>
<div id="${sectionId}" style="display: block; padding: 15px;">
${content}
</div>
</div>
`;
}
function fetchAllLogEventsBatched(api, params, results = [], batchSize = 50, delay = 200) {
var cacheKey = getCacheKey(api, params);
var cached = getCachedResult(cacheKey);
if (cached) {
return Promise.resolve(cached);
}
var currentParams = {...params, lelimit: Math.min(batchSize, params.lelimit || 500)};
function fetchBatch() {
return new Promise((resolve, reject) => {
var timeout = setTimeout(() => {
reject(new Error('API request timeout'));
}, 15000);
api.get(currentParams).then(response => {
clearTimeout(timeout);
var newResults = response.query.logevents || [];
results.push(...newResults);
if (response.continue && results.length < (params.lelimit || 500)) {
currentParams = {...currentParams, ...response.continue};
setTimeout(() => {
fetchBatch().then(resolve).catch(reject);
}, delay);
} else {
setCachedResult(cacheKey, results);
resolve(results);
}
}).catch(error => {
clearTimeout(timeout);
reject(error);
});
});
}
return fetchBatch();
}
function fetchAllRevisionsBatched(api, params, results = [], batchSize = 50, delay = 200) {
var cacheKey = getCacheKey(api, params);
var cached = getCachedResult(cacheKey);
if (cached) {
return Promise.resolve(cached);
}
var currentParams = {...params, rvlimit: Math.min(batchSize, params.rvlimit || 500)};
function fetchBatch() {
return new Promise((resolve, reject) => {
var timeout = setTimeout(() => {
reject(new Error('API request timeout'));
}, 15000);
api.get(currentParams).then(response => {
clearTimeout(timeout);
const pages = response.query.pages || {};
Object.values(pages).forEach(page => {
if (page.revisions) {
results.push(...page.revisions);
}
});
if (response.continue && results.length < (params.rvlimit || 500)) {
currentParams = {...currentParams, ...response.continue};
setTimeout(() => {
fetchBatch().then(resolve).catch(reject);
}, delay);
} else {
setCachedResult(cacheKey, results);
resolve(results);
}
}).catch(error => {
clearTimeout(timeout);
reject(error);
});
});
}
return fetchBatch();
}
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php', { headers: { 'Api-User-Agent': 'User:DreamRimmer/adminnewslettertools.js; https://en.wikipedia.org/wiki/User:DreamRimmer' } });
const localApi = new mw.Api({ headers: { 'Api-User-Agent': 'User:DreamRimmer/adminnewslettertools.js; https://en.wikipedia.org/wiki/User:DreamRimmer' } });
function fetchRightsChanges(startTs, endTs, startLabel, endLabel) {
return Promise.all([
fetchAllLogEventsBatched(metaApi, {
action: 'query',
list: 'logevents',
letype: 'rights',
lestart: endTs,
leend: startTs,
lelimit: 500,
leprop: 'timestamp|title|user|userid|details|params|type|comment|ids',
format: 'json'
}),
fetchAllLogEventsBatched(localApi, {
action: 'query',
list: 'logevents',
letype: 'rights',
lestart: endTs,
leend: startTs,
lelimit: 500,
leprop: 'timestamp|title|user|userid|details|params|type|comment|ids',
format: 'json'
})
]).then(([metaEntries, localEntries]) => {
const metaFiltered = metaEntries.filter(entry =>
entry.title && entry.title.endsWith('@enwiki')
);
const targetMeta = ['suppress', 'checkuser'];
const targetLocal = ['sysop', 'interface-admin', 'bureaucrat'];
const metaResults = processLogEntries(metaFiltered, targetMeta);
const localResults = processLogEntries(localEntries, targetLocal);
const tablesHtml = createRightsTable('Meta granted', metaResults.granted, true) +
createRightsTable('Meta removed', metaResults.removed, true) +
createRightsTable('Local granted', localResults.granted, false) +
createRightsTable('Local removed', localResults.removed, false);
return tablesHtml || '<p>No rights changes found for this period.</p>';
});
}
function fetchTechNewsBatched(year, month) {
const weeks = getWeeksInMonth(year, month);
var cacheKey = `tech_news_${year}_${month}`;
var cached = getCachedResult(cacheKey);
if (cached) {
return Promise.resolve(cached);
}
function fetchWeekBatch(weekBatch) {
return Promise.all(weekBatch.map(week => {
return new Promise((resolve) => {
var timeout = setTimeout(() => {
resolve(null);
}, 10000);
metaApi.get({
action: 'parse',
page: `Tech/News/${year}/${week}`,
format: 'json',
prop: 'text'
}).then(response => {
clearTimeout(timeout);
if (response.parse && response.parse.text) {
const html = response.parse.text['*'];
const match = html.match(/(\d{4}), week (\d+) \(([^)]+)\)/);
if (match) {
const weekDate = match[3];
const parsedDate = new Date(weekDate);
if (parsedDate.getMonth() === month - 1) {
resolve({ week, date: weekDate, url: `https://meta.wikimedia.org/wiki/Tech/News/${year}/${week}` });
return;
}
}
}
resolve(null);
}).catch(() => {
clearTimeout(timeout);
resolve(null);
});
});
}));
}
const batchSize = 2;
const batches = [];
for (let i = 0; i < weeks.length; i += batchSize) {
batches.push(weeks.slice(i, i + batchSize));
}
return batches.reduce((promise, batch) => {
return promise.then(results => {
return fetchWeekBatch(batch).then(batchResults => {
return results.concat(batchResults);
});
});
}, Promise.resolve([])).then(results => {
const validWeeks = results.filter(r => r !== null);
let html;
if (!validWeeks.length) {
html = '<p>No tech news found for this period.</p>';
} else {
html = '<h4>Tech News Links</h4><ul>';
validWeeks.forEach(({ week, date, url }) => {
html += `<li><a href="${url}" target="_blank">Tech/News/${year}/${week}</a> (${date})</li>`;
});
html += '</ul>';
}
setCachedResult(cacheKey, html);
return html;
});
}
function fetchCentralizedDiscussion(startTs, endTs) {
return fetchAllRevisionsBatched(localApi, {
action: 'query',
titles: 'Template:Centralized discussion',
prop: 'revisions',
rvstart: endTs,
rvend: startTs,
rvlimit: 500,
rvprop: 'timestamp|user|comment|ids',
format: 'json'
}).then(revisions => {
return createEditTable('CENT edits', revisions, true);
});
}
function fetchArbComNotices(startTs, endTs) {
return fetchAllRevisionsBatched(localApi, {
action: 'query',
titles: 'Wikipedia:Arbitration Committee/Noticeboard',
prop: 'revisions',
rvstart: endTs,
rvend: startTs,
rvlimit: 500,
rvprop: 'timestamp|user|comment|tags|ids',
format: 'json'
}).then(revisions => {
const filtered = revisions.filter(rev => rev.tags && rev.tags.includes('discussiontools-newtopic'));
return createEditTable('ArbCom topics', filtered, true);
});
}
function fetchArbComCases() {
var cacheKey = 'arbcom_cases';
var cached = getCachedResult(cacheKey);
if (cached) {
return Promise.resolve(cached);
}
return Promise.all([
new Promise((resolve) => {
var timeout = setTimeout(() => resolve({ parse: null }), 10000);
localApi.get({
action: 'parse',
page: 'Template:ArbComOpenTasks/Cases',
format: 'json',
prop: 'text'
}).then(response => {
clearTimeout(timeout);
resolve(response);
}).catch(() => {
clearTimeout(timeout);
resolve({ parse: null });
});
}),
new Promise((resolve) => {
var timeout = setTimeout(() => resolve({ parse: null }), 10000);
localApi.get({
action: 'parse',
page: 'Template:ArbComOpenTasks/ClosedCases',
format: 'json',
prop: 'text'
}).then(response => {
clearTimeout(timeout);
resolve(response);
}).catch(() => {
clearTimeout(timeout);
resolve({ parse: null });
});
})
]).then(([openResponse, closedResponse]) => {
let html = '';
if (openResponse.parse && openResponse.parse.text) {
const openHtml = openResponse.parse.text['*'];
const openCases = parseArbComCases(openHtml, 'open');
html += createArbComTable('Open cases', openCases, 'open');
} else {
html += '<h4>Open cases</h4><p>No cases are in this period open.</p>';
}
if (closedResponse.parse && closedResponse.parse.text) {
const closedHtml = closedResponse.parse.text['*'];
const closedCases = parseArbComCases(closedHtml, 'closed');
html += createArbComTable('Recently closed cases', closedCases, 'closed');
} else {
html += '<h4>Recently closed cases</h4><p>No cases are in this period closed.</p>';
}
html += '<p><a href="https://en.wikipedia.org/wiki/Template:ArbComOpenTasks" target="_blank">See detailed statistics</a></p>';
setCachedResult(cacheKey, html);
return html;
});
}
function parseArbComCases(html, type) {
const cases = [];
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const rows = doc.querySelectorAll('table.wikitable tbody tr');
rows.forEach((row, index) => {
if (index === 0) return;
const cells = row.querySelectorAll('td');
if (cells.length === 0) return;
const firstCell = cells[0];
const link = firstCell.querySelector('a');
if (!link) return;
const caseName = link.textContent.trim();
const href = link.getAttribute('href');
const fullUrl = href.startsWith('/wiki/') ? `https://en.wikipedia.org${href}` : href;
if (type === 'open' && cells.length >= 4) {
const evidenceDeadline = cells[2] ? cells[2].textContent.trim() : '';
const pdDeadline = cells[3] ? cells[3].textContent.trim() : '';
cases.push({
name: caseName,
url: fullUrl,
evidenceDeadline: evidenceDeadline,
pdDeadline: pdDeadline
});
} else if (type === 'closed' && cells.length >= 2) {
const dateClosed = cells[1] ? cells[1].textContent.trim() : '';
cases.push({
name: caseName,
url: fullUrl,
dateClosed: dateClosed
});
}
});
return cases;
}
function createArbComTable(title, cases, type) {
let html = `<h4>${title}</h4>`;
if (cases.length === 0) {
const period = type === 'open' ? 'open' : 'closed';
html += `<p>No cases are in this period ${period}.</p>`;
return html;
}
if (type === 'open') {
html += '<table class="wikitable"><thead><tr><th>Case name</th><th>Evidence deadline</th><th>PD deadline</th></tr></thead><tbody>';
cases.forEach(caseItem => {
html += `<tr><td><i><a href="${caseItem.url}" target="_blank">${caseItem.name}</a></i></td><td>${caseItem.evidenceDeadline}</td><td>${caseItem.pdDeadline}</td></tr>`;
});
} else {
html += '<table class="wikitable"><thead><tr><th>Case name</th><th>Date closed</th></tr></thead><tbody>';
cases.forEach(caseItem => {
html += `<tr><td><i><a href="${caseItem.url}" target="_blank">${caseItem.name}</a></i></td><td>${caseItem.dateClosed}</td></tr>`;
});
}
html += '</tbody></table>';
return html;
}
function fetchRecentRfCsBatched() {
var cacheKey = 'recent_rfcs';
var cached = getCachedResult(cacheKey);
if (cached) {
return Promise.resolve(cached);
}
const rfcPages = [
{ name: 'Biographies', url: 'Wikipedia:Requests_for_comment/Biographies' },
{ name: 'Economy, trade, and companies', url: 'Wikipedia:Requests_for_comment/Economy,_trade,_and_companies' },
{ name: 'History and geography', url: 'Wikipedia:Requests_for_comment/History_and_geography' },
{ name: 'Language and linguistics', url: 'Wikipedia:Requests_for_comment/Language_and_linguistics' },
{ name: 'Maths, science, and technology', url: 'Wikipedia:Requests_for_comment/Maths,_science,_and_technology' },
{ name: 'Art, architecture, literature, and media', url: 'Wikipedia:Requests_for_comment/Art,_architecture,_literature,_and_media' },
{ name: 'Politics, government, and law', url: 'Wikipedia:Requests_for_comment/Politics,_government,_and_law' },
{ name: 'Religion and philosophy', url: 'Wikipedia:Requests_for_comment/Religion_and_philosophy' },
{ name: 'Society, sports, and culture', url: 'Wikipedia:Requests_for_comment/Society,_sports,_and_culture' },
{ name: 'Wikipedia style and naming', url: 'Wikipedia:Requests_for_comment/Wikipedia_style_and_naming' },
{ name: 'Wikipedia policies and guidelines', url: 'Wikipedia:Requests_for_comment/Wikipedia_policies_and_guidelines' },
{ name: 'WikiProjects and collaborations', url: 'Wikipedia:Requests_for_comment/WikiProjects_and_collaborations' },
{ name: 'Wikipedia technical issues and templates', url: 'Wikipedia:Requests_for_comment/Wikipedia_technical_issues_and_templates' },
{ name: 'Wikipedia proposals', url: 'Wikipedia:Requests_for_comment/Wikipedia_proposals' },
{ name: 'Unsorted', url: 'Wikipedia:Requests_for_comment/Unsorted' },
{ name: 'User names', url: 'Wikipedia:Requests_for_comment/User_names' }
];
function fetchPageBatch(pageBatch) {
return Promise.all(pageBatch.map(page => {
return new Promise((resolve) => {
var timeout = setTimeout(() => {
resolve([]);
}, 10000);
localApi.get({
action: 'parse',
page: page.url,
format: 'json',
prop: 'wikitext'
}).then(response => {
clearTimeout(timeout);
if (response.parse && response.parse.wikitext) {
const wikitext = response.parse.wikitext['*'];
const rfcs = parseRfCs(wikitext, page.name);
resolve(rfcs);
} else {
resolve([]);
}
}).catch(() => {
clearTimeout(timeout);
resolve([]);
});
});
}));
}
const batchSize = 3;
const batches = [];
for (let i = 0; i < rfcPages.length; i += batchSize) {
batches.push(rfcPages.slice(i, i + batchSize));
}
return batches.reduce((promise, batch, index) => {
return promise.then(results => {
return fetchPageBatch(batch).then(batchResults => {
// Add delay between batches except for the last one
if (index < batches.length - 1) {
return new Promise(resolve => {
setTimeout(() => {
resolve(results.concat(batchResults.flat()));
}, 300);
});
} else {
return results.concat(batchResults.flat());
}
});
});
}, Promise.resolve([])).then(allRfcs => {
const result = createRfCDisplay(allRfcs);
setCachedResult(cacheKey, result);
return result;
});
}
function parseRfCs(wikitext, topicName) {
const rfcs = [];
const rfcEntryPattern = /'''\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'''[\s\S]*?\{\{rfcquote\|text=([\s\S]*?)\}\}[\s\S]*?(\d{2}:\d{2}, \d{1,2} \w+ \d{4} \(UTC\))/g;
const now = new Date();
const currentUtcYear = now.getUTCFullYear();
const currentUtcMonth = now.getUTCMonth();
const currentUtcDate = now.getUTCDate();
const currentUtcHours = now.getUTCHours();
const currentUtcMinutes = now.getUTCMinutes();
const currentUtc = new Date(Date.UTC(currentUtcYear, currentUtcMonth, currentUtcDate, currentUtcHours, currentUtcMinutes, 0, 0));
let match;
while ((match = rfcEntryPattern.exec(wikitext)) !== null) {
let pageName, anchor, displayTitle;
const link = match[1];
displayTitle = match[2] || link;
if (link.includes('#')) {
[pageName, anchor] = link.split('#');
} else {
pageName = link;
anchor = '';
}
const timestampStr = match[4];
let daysDiff = null;
if (timestampStr) {
const tsMatch = timestampStr.match(/(\d{2}):(\d{2}), (\d{1,2}) (\w+) (\d{4}) \(UTC\)/);
if (tsMatch) {
const [_, hour, minute, day, monthText, year] = tsMatch;
const months = [
"January","February","March","April","May","June","July","August","September","October","November","December"
];
const monthNum = months.indexOf(monthText);
if (monthNum !== -1) {
const tsDate = new Date(Date.UTC(
parseInt(year,10),
monthNum,
parseInt(day,10),
parseInt(hour,10),
parseInt(minute,10),
0, 0
));
daysDiff = Math.floor((currentUtc - tsDate) / (1000 * 60 * 60 * 24));
}
}
}
const url = anchor ?
`https://en.wikipedia.org/wiki/${encodeURIComponent(pageName)}#${encodeURIComponent(anchor)}` :
`https://en.wikipedia.org/wiki/${encodeURIComponent(pageName)}`;
rfcs.push({
topic: topicName,
title: displayTitle || pageName,
url: url,
daysOld: daysDiff,
timestamp: timestampStr || ""
});
}
return rfcs;
}
function createRfCDisplay(rfcs) {
const currentDate = new Date().toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC'
}) + ' UTC';
const ageOptions = [3, 7, 10, 15, 20, 25, 30];
const topicNames = [
'Biographies', 'Economy, trade, and companies', 'History and geography',
'Language and linguistics', 'Maths, science, and technology',
'Art, architecture, literature, and media', 'Politics, government, and law',
'Religion and philosophy', 'Society, sports, and culture',
'Wikipedia style and naming', 'Wikipedia policies and guidelines',
'WikiProjects and collaborations', 'Wikipedia technical issues and templates',
'Wikipedia proposals', 'Unsorted', 'User names'
];
let html = `<p><small>Current date: ${currentDate}</small></p>`;
html += `<p>Age filter: <select id="rfc-age-filter">`;
ageOptions.forEach(age => {
const selected = age === 7 ? ' selected' : '';
html += `<option value="${age}"${selected}>${age} days old</option>`;
});
html += `</select></p>`;
function renderRfCTable(filteredRfcs) {
if (filteredRfcs.length > 0) {
let table = '<h4>Current RfCs</h4><table class="wikitable sortable"><thead><tr><th>Topic</th><th>Title</th><th>Days Old</th><th>Started</th></tr></thead><tbody>';
filteredRfcs.sort((a, b) => a.daysOld - b.daysOld).forEach(rfc => {
table += `<tr><td>${rfc.topic}</td><td><a href="${rfc.url}" target="_blank">${rfc.title}</a></td><td>${rfc.daysOld}</td><td>${rfc.timestamp}</td></tr>`;
});
table += '</tbody></table>';
return table;
} else {
return `<p>No RfCs found for selected age filter.</p>`;
}
}
html += `<div id="rfc-list-table"></div>`;
html += '<p><a href="https://en.wikipedia.org/wiki/Wikipedia:Requests_for_comment/All" target="_blank">All open RfCs</a></p>';
html += `<script>
window._rfcs = ${JSON.stringify(rfcs)};
window._ageOptions = ${JSON.stringify(ageOptions)};
function updateRfCTable() {
var rfcs = window._rfcs;
var age = parseInt(document.getElementById('rfc-age-filter').value, 10);
var filteredRfcs = rfcs.filter(function(rfc) {
return !isNaN(rfc.daysOld) && rfc.daysOld <= age;
});
document.getElementById('rfc-list-table').innerHTML = (${renderRfCTable.toString()})(filteredRfcs);
}
document.getElementById('rfc-age-filter').addEventListener('change', updateRfCTable);
updateRfCTable();
</script>`;
return html;
}
function checkPageExists(title) {
var cacheKey = `page_exists_${title}`;
var cached = getCachedResult(cacheKey);
if (cached !== null) {
return Promise.resolve(cached);
}
return new Promise((resolve) => {
var timeout = setTimeout(() => {
setCachedResult(cacheKey, false);
resolve(false);
}, 10000);
localApi.get({
action: 'query',
titles: title,
format: 'json'
}).then(response => {
clearTimeout(timeout);
const pages = response.query.pages || {};
const exists = !Object.values(pages).some(page => page.hasOwnProperty('missing'));
setCachedResult(cacheKey, exists);
resolve(exists);
}).catch(() => {
clearTimeout(timeout);
setCachedResult(cacheKey, false);
resolve(false);
});
});
}
function fetchData() {
var selectedItem = monthSelect.getMenu().findSelectedItem();
if (!selectedItem) {
OO.ui.alert('Please select a month');
return;
}
const month = parseInt(selectedItem.getData());
const year = parseInt(yearInput.getValue());
const section = sectionSelect.getMenu().findSelectedItem().getData();
if (year < 2005 || year > new Date().getFullYear() + 1) {
OO.ui.alert('Please enter a valid year between 2005 and ' + (new Date().getFullYear() + 1));
return;
}
const currentDate = new Date();
const isCurrentOrLastMonth = (year === currentDate.getFullYear() && month >= currentDate.getMonth()) ||
(year === currentDate.getFullYear() - 1 && month === 12 && currentDate.getMonth() === 0);
const startDt = new Date(Date.UTC(year, month - 1, 1, 0, 0, 0));
const nextMonthDt = new Date(Date.UTC(month === 12 ? year + 1 : year, month % 12, 1, 0, 0, 0));
const startTs = startDt.toISOString();
const endTs = nextMonthDt.toISOString();
const startLabel = formatDate(startDt);
const endLabel = formatDate(new Date(nextMonthDt.getTime() - 1000));
resultsContainer.empty();
resultsContainer.append($('<p>').text('Loading...'));
resultsContainer.show();
checkButton.setDisabled(true);
checkButton.setLabel('Loading...');
const promises = [];
const sections = [];
if (section === 'all' || section === 'rights') {
promises.push(fetchRightsChanges(startTs, endTs, startLabel, endLabel));
sections.push('rights');
}
if (section === 'all' || section === 'tech') {
promises.push(fetchTechNewsBatched(year, month));
sections.push('tech');
}
if (section === 'all' || section === 'centralized') {
promises.push(fetchCentralizedDiscussion(startTs, endTs));
sections.push('centralized');
}
if (section === 'all' || section === 'arbcom') {
promises.push(fetchArbComNotices(startTs, endTs));
sections.push('arbcom');
}
if ((section === 'all' || section === 'rfc') && isCurrentOrLastMonth) {
promises.push(fetchRecentRfCsBatched());
sections.push('rfc');
}
Promise.all(promises).then(results => {
resultsContainer.empty();
const headerHtml = `<h2>Results from ${startLabel} to ${endLabel}</h2>`;
resultsContainer.append(headerHtml);
window.toggleSection = function(sectionId) {
const content = document.getElementById(sectionId);
const toggle = document.getElementById(sectionId + '-toggle');
if (content.style.display === 'none') {
content.style.display = 'block';
toggle.textContent = '[hide]';
} else {
content.style.display = 'none';
toggle.textContent = '[show]';
}
};
let resultIndex = 0;
if (sections.includes('rights')) {
const rightsHtml = createCollapsibleSection('Rights changes', results[resultIndex]);
resultsContainer.append(rightsHtml);
resultIndex++;
}
if (sections.includes('tech')) {
const techHtml = createCollapsibleSection('Tech News', results[resultIndex]);
resultsContainer.append(techHtml);
resultIndex++;
}
if (sections.includes('centralized')) {
const centralizedHtml = createCollapsibleSection('CENT', results[resultIndex]);
resultsContainer.append(centralizedHtml);
resultIndex++;
}
if (sections.includes('arbcom')) {
const arbcomHtml = createCollapsibleSection('ArbCom updates', results[resultIndex]);
resultsContainer.append(arbcomHtml);
resultIndex++;
fetchArbComCases().then(arbcomCasesHtml => {
const arbcomTasksHtml = createCollapsibleSection('Arbitration Committee open and recently closed cases', arbcomCasesHtml);
resultsContainer.append(arbcomTasksHtml);
});
}
if (sections.includes('rfc')) {
const rfcHtml = createCollapsibleSection('Recent RfCs', results[resultIndex]);
resultsContainer.append(rfcHtml);
resultIndex++;
}
if (section === 'all' || section === 'misc') {
const miscPromises = [];
let miscSections = [];
if (year >= 2025 && isCurrentOrLastMonth) {
miscSections.push('<h4>Backlog drive schedule</h4><p><a href="https://en.wikipedia.org/wiki/Wikipedia:Backlog_drive_schedule" target="_blank">Wikipedia:Backlog drive schedule</a></p>');
}
if ((year > 2024 || (year === 2024 && month >= 10)) && isCurrentOrLastMonth) {
miscSections.push('<h4>Administrator elections</h4><p><a href="https://en.wikipedia.org/wiki/Wikipedia:Administrator_elections" target="_blank">Administrator elections</a> updates</p>');
}
if (year >= 2006) {
miscPromises.push(
checkPageExists(`Wikipedia:Arbitration_Committee_Elections_December_${year}`)
.then(exists => exists ? `<h4>Arbitration Committee elections</h4><p><a href="https://en.wikipedia.org/wiki/Wikipedia:Arbitration_Committee_Elections_December_${year}" target="_blank">Arbitration Committee elections</a></p>` : '')
);
} else {
miscPromises.push(Promise.resolve(''));
}
if (year >= 2024 && isCurrentOrLastMonth) {
miscSections.push('<h4>UCoC election updates</h4><p><a href="https://meta.wikimedia.org/wiki/Universal_Code_of_Conduct/Coordinating_Committee/Election" target="_blank">UCoC election updates</a></p>');
}
if (miscPromises.length > 0) {
Promise.all(miscPromises).then(dynamicSections => {
let miscContent = miscSections.join('') + dynamicSections.join('');
if (miscContent) {
const miscHtml = createCollapsibleSection('Miscellaneous', miscContent);
resultsContainer.append(miscHtml);
}
});
} else {
let miscContent = miscSections.join('');
if (miscContent) {
const miscHtml = createCollapsibleSection('Miscellaneous', miscContent);
resultsContainer.append(miscHtml);
}
}
}
}).catch(error => {
console.error('Error:', error);
resultsContainer.empty();
resultsContainer.append($('<p>').css('color', 'red').text(`Error fetching data: ${error}`));
}).finally(() => {
checkButton.setDisabled(false);
checkButton.setLabel('Check');
});
}
checkButton.on('click', fetchData);
}
$.when(mw.loader.using('mediawiki.util'), $.ready).then(function() {
mw.util.addPortletLink(
'p-tb',
mw.util.getUrl('Special:BlankPage/AdminNewsTools'),
'Administrators\' newsletter tools',
't-adminnewstools',
'View various Administrators\' newsletter related updates for a specific month'
);
});
if (mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage' && mw.config.get('wgTitle').split('/', 2)[1] === 'AdminNewsTools') {
$.when(
mw.loader.using([
'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows'
]),
$.ready
).then(function() {
initializeAdminNewsTools();
});
}
});
//</nowiki>