/**
* AdminNewsletterTools
* Administrators' newsletter related updates for a specific month
* @author DreamRimmer ([[en:User:DreanRimmer]])
*
* <nowiki>
*/
$(document).ready(function() {
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,
showButtons: true
}),
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');
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') || rev.tags.includes('convenient-discussions')));
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 => {
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=((?:[^{}]|\{[^{]|\}[^}]|\{\{(?:[^{}]|\{[^{]|\}[^}])*\}\})*)\}\}/gi;
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] || (anchor ? `${link}#${anchor}` : link);
if (link.includes('#')) {
[pageName, anchor] = link.split('#');
} else {
pageName = link;
anchor = '';
}
const rfcText = match[3];
let timestampStr = null;
let daysDiff = null;
const timestampMatch = rfcText.match(/(\d{2}):(\d{2}), (\d{1,2}) (\w+) (\d{4}) \(UTC\)/);
if (timestampMatch) {
const [fullMatch, hour, minute, day, monthText, year] = timestampMatch;
timestampStr = fullMatch;
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
));
const timeDiff = currentUtc.getTime() - tsDate.getTime();
daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
if (daysDiff < 0) daysDiff = 0;
}
}
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 || "unavailable"
});
}
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 rfcMap = new Map();
rfcs.forEach(rfc => {
const uniqueKey = `${rfc.url}|${rfc.title}`;
if (rfcMap.has(uniqueKey)) {
const existing = rfcMap.get(uniqueKey);
if (!existing.topics.includes(rfc.topic)) {
existing.topics.push(rfc.topic);
}
} else {
rfcMap.set(uniqueKey, {
...rfc,
topics: [rfc.topic]
});
}
});
const deduplicatedRfcs = Array.from(rfcMap.values());
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>RfC</th><th>Days Old</th><th>Started</th></tr></thead><tbody>';
filteredRfcs.sort((a, b) => a.daysOld - b.daysOld).forEach((rfc, index) => {
let topicDisplay;
if (rfc.topics.length === 1) {
topicDisplay = rfc.topics[0];
} else {
const mainTopic = rfc.topics[0];
const additionalCount = rfc.topics.length - 1;
const additionalTopics = rfc.topics.slice(1).join(', ');
topicDisplay = `
<span class="main-topic">${mainTopic}</span>
<span class="additional-topics" style="color: #0645ad; cursor: pointer; text-decoration: underline;"
onclick="toggleTopics('topics-${index}')"
title="${additionalTopics}">
(+${additionalCount} more)
</span>
<div id="topics-${index}" style="display: none; margin-top: 5px; font-size: 0.9em; color: #666;">
${rfc.topics.slice(1).map(topic => `<div>• ${topic}</div>`).join('')}
</div>
`;
}
table += `<tr><td>${topicDisplay}</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(deduplicatedRfcs)};
window._ageOptions = ${JSON.stringify(ageOptions)};
function toggleTopics(elementId) {
var element = document.getElementById(elementId);
if (element.style.display === 'none') {
element.style.display = 'block';
} else {
element.style.display = 'none';
}
}
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>