User:DreamRimmer/adminnewslettertools.js

Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/**
 * 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>&bull;</span>
            <a href="https://en.wikipedia.org/wiki/Wikipedia:Administrators%27_newsletter/Archive" target="_blank">Archive</a>
            <span>&bull;</span>
            <a href="https://en.wikipedia.org/wiki/Wikipedia:Administrators%27_newsletter/Subscribe" target="_blank">Subscribe</a>
            <span>&bull;</span>
            <a href="https://en.wikipedia.org/wiki/Wikipedia:Administrators%27_newsletter/Write" target="_blank">Write</a>
            <span>&bull;</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>