Jump to content

User:Polygnotus/Scripts/DiffViewer.js

From Wikipedia, the free encyclopedia
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.
// <nowiki>
(function() {
    'use strict';

    const DEBUG = true;

    function debug(...args) {
        if (DEBUG) {
            console.log('[DiffViewer]', ...args);
        }
    }

    const CURRENT_WIKI = mw.config.get('wgServerName').replace('www.', '');
    const API_ENDPOINT = mw.config.get('wgServer') + mw.config.get('wgScriptPath') + '/api.php';
    const CURRENT_USER = mw.config.get('wgUserName') || 'Anonymous';
    const USER_AGENT = `DiffViewer/1.0 (${mw.config.get('wgServer')}/wiki/User:${encodeURIComponent(CURRENT_USER)})`;
    const API_DELAY = 1000;
    const MAX_RETRIES = 5;
    const RETRY_DELAYS = [30000, 60000, 120000, 300000, 600000];

    let apiCallCount = 0;
    let abortController = null;
    let originalOrder = [];

    debug('Initialized with config:', {
        CURRENT_WIKI,
        API_ENDPOINT,
        CURRENT_USER,
        USER_AGENT
    });

    // Check for jQuery
    if (typeof $ === 'undefined' || typeof mw === 'undefined') {
        console.error('DiffViewer requires jQuery and MediaWiki environment');
        return;
    }

    // Add link to Tools menu on all pages
    $(document).ready(function() {
        debug('Adding portlet link to tools menu');
        mw.util.addPortletLink(
            'p-tb',
            '/wiki/Special:BlankPage/DiffViewer',
            'DiffViewer',
            't-diffviewer',
            'View and compare user contributions with diffs'
        );
    });

    // Check if we're on the correct page
    if (mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage' &&
        mw.config.get('wgPageName') === 'Special:BlankPage/DiffViewer') {

        debug('On DiffViewer page, initializing');
        document.title = 'DiffViewer - ' + CURRENT_WIKI;
        $('#firstHeading').text('DiffViewer');
        initializePage();
    }

    function initializePage() {
        debug('initializePage called');
        const html = `
            <div id="diff-viewer-container">
                <div id="input-section" style="margin: 20px 0; padding: 20px; border: 1px solid #ddd; border-radius: 5px;">
                    <h3>Fetch User Contributions</h3>
                    <div style="margin: 10px 0;">
                        <label style="display: inline-block; width: 150px;">Usernames (up to 5):</label>
                        <div style="display: inline-block; vertical-align: top;">
                            <div style="margin-bottom: 5px;">
                                <span class="color-indicator" style="display: inline-block; width: 20px; height: 20px; background-color: #3366cc; border: 1px solid #000; vertical-align: middle; margin-right: 5px;"></span>
                                <input type="text" class="username-input" data-index="0" data-bwignore="true" style="width: 280px; padding: 5px;" placeholder="Username 1">
                            </div>
                            <div style="margin-bottom: 5px;">
                                <span class="color-indicator" style="display: inline-block; width: 20px; height: 20px; background-color: #dc3912; border: 1px solid #000; vertical-align: middle; margin-right: 5px;"></span>
                                <input type="text" class="username-input" data-index="1" data-bwignore="true" style="width: 280px; padding: 5px;" placeholder="Username 2 (optional)">
                            </div>
                            <div style="margin-bottom: 5px;">
                                <span class="color-indicator" style="display: inline-block; width: 20px; height: 20px; background-color: #ff9900; border: 1px solid #000; vertical-align: middle; margin-right: 5px;"></span>
                                <input type="text" class="username-input" data-index="2" data-bwignore="true" style="width: 280px; padding: 5px;" placeholder="Username 3 (optional)">
                            </div>
                            <div style="margin-bottom: 5px;">
                                <span class="color-indicator" style="display: inline-block; width: 20px; height: 20px; background-color: #109618; border: 1px solid #000; vertical-align: middle; margin-right: 5px;"></span>
                                <input type="text" class="username-input" data-index="3" data-bwignore="true" style="width: 280px; padding: 5px;" placeholder="Username 4 (optional)">
                            </div>
                            <div style="margin-bottom: 5px;">
                                <span class="color-indicator" style="display: inline-block; width: 20px; height: 20px; background-color: #990099; border: 1px solid #000; vertical-align: middle; margin-right: 5px;"></span>
                                <input type="text" class="username-input" data-index="4" data-bwignore="true" style="width: 280px; padding: 5px;" placeholder="Username 5 (optional)">
                            </div>
                        </div>
                    </div>
                    <div style="margin: 10px 0;">
                        <label for="limit-input" style="display: inline-block; width: 150px;">Limit per user (0 for all):</label>
                        <input type="number" id="limit-input" value="50" min="0" style="width: 300px; padding: 5px;">
                    </div>
                    <div style="margin: 10px 0;">
                        <label for="start-timestamp" style="display: inline-block; width: 150px;">Start date:</label>
                        <input type="text" id="start-timestamp" style="width: 300px; padding: 5px;" placeholder="YYYY-MM-DD (optional)">
                    </div>
                    <div style="margin: 10px 0;">
                        <label for="end-timestamp" style="display: inline-block; width: 150px;">End date:</label>
                        <input type="text" id="end-timestamp" style="width: 300px; padding: 5px;" placeholder="YYYY-MM-DD (optional)">
                    </div>
                    <div style="margin: 10px 0;">
                        <button id="fetch-btn" style="padding: 10px 20px; font-size: 14px; cursor: pointer;">Fetch Contributions</button>
                        <button id="abort-btn" style="padding: 10px 20px; font-size: 14px; cursor: pointer; margin-left: 10px; display: none;">Abort</button>
                    </div>
                    <div id="progress-section" style="margin-top: 20px; display: none;">
                        <div id="progress-bar-container" style="width: 100%; height: 30px; background-color: #f0f0f0; border-radius: 5px; overflow: hidden;">
                            <div id="progress-bar" style="height: 100%; background-color: #4CAF50; width: 0%; transition: width 0.3s;"></div>
                        </div>
                        <p id="progress-text" style="margin-top: 10px;"></p>
                    </div>
                    <div id="status-section" style="margin-top: 10px; color: #666;"></div>
                </div>
                <div id="results-section"></div>
            </div>
        `;

        $('#mw-content-text').html(html);

        $('#fetch-btn').on('click', fetchContributions);
        $('#abort-btn').on('click', abortFetch);
        debug('Page initialized, event handlers attached');
    }

    function abortFetch() {
        debug('Abort button clicked');
        if (abortController) {
            abortController.abort();
            updateStatus('Fetch aborted by user');
            debug('Fetch aborted');
        }
    }

    function normalizeTimestamp(dateString, isEndDate = false) {
        debug('normalizeTimestamp called with:', dateString, 'isEndDate:', isEndDate);
        if (!dateString) return null;
        
        dateString = dateString.trim();
        
        // If already a full timestamp, validate and return
        if (dateString.includes('T')) {
            const date = new Date(dateString);
            if (isNaN(date.getTime())) {
                throw new Error(`Invalid timestamp: ${dateString}`);
            }
            debug('Normalized full timestamp:', dateString);
            return dateString;
        }
        
        // If just a date (YYYY-MM-DD), validate and add time
        if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
            const date = new Date(dateString);
            if (isNaN(date.getTime())) {
                throw new Error(`Invalid date: ${dateString}. Use YYYY-MM-DD format.`);
            }
            const normalized = isEndDate 
                ? `${dateString}T23:59:59Z` 
                : `${dateString}T00:00:00Z`;
            debug('Normalized date to:', normalized);
            return normalized;
        }
        
        throw new Error(`Invalid date format: ${dateString}. Use YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ`);
    }

    const USER_COLORS = ['#3366cc', '#dc3912', '#ff9900', '#109618', '#990099'];

    async function fetchContributions() {
        debug('fetchContributions called');


//make the usernames uppercase in any language. For example the Cyrillic username иван is turned into Иван
$('.username-input').each(function() {
    const value = $(this).val().trim();
    if (value && /^\p{Ll}/u.test(value)) {
        $(this).val(value.charAt(0).toUpperCase() + value.slice(1));
    }
});


        const usernames = [];
        $('.username-input').each(function() {
            const value = $(this).val().trim();
            if (value) {
                usernames.push(value);
            }
        });

        debug('Usernames collected:', usernames);

        const limit = parseInt($('#limit-input').val()) || 0;
        debug('Limit:', limit);
        
        let startTimestamp, endTimestamp;
        try {
            startTimestamp = normalizeTimestamp($('#start-timestamp').val(), false);
            endTimestamp = normalizeTimestamp($('#end-timestamp').val(), true);
            debug('Timestamps - start:', startTimestamp, 'end:', endTimestamp);
        } catch (error) {
            debug('Timestamp validation error:', error);
            alert(error.message);
            return;
        }

        if (usernames.length === 0) {
            debug('No usernames provided');
            alert('Please enter at least one username');
            return;
        }

        // Initialize abort controller early
        abortController = new AbortController();
        debug('AbortController initialized');

        // Fetch edit counts first to check if we need confirmation
        const editCounts = [];
        let totalEditsToFetch = 0;
        
        for (let i = 0; i < usernames.length; i++) {
            const username = usernames[i];
            debug(`Fetching edit count for user ${i + 1}/${usernames.length}:`, username);
            try {
                const editCount = await getEditCountFromAPI(username);
                debug(`${username} has ${editCount} edits`);
                editCounts.push(editCount);
                const editsForThisUser = limit === 0 ? editCount : Math.min(limit, editCount);
                totalEditsToFetch += editsForThisUser;
                debug(`Will fetch ${editsForThisUser} edits for ${username}`);
            } catch (error) {
                debug(`Failed to fetch edit count for ${username}:`, error);
                alert(`Could not fetch edit count for ${username}: ${error.message}`);
                abortController = null;
                return;
            }
        }

        debug(`Total edits to fetch: ${totalEditsToFetch}`);

        // Show confirmation dialog if fetching more than 1000 edits
        if (totalEditsToFetch > 1000) {
            debug('Showing confirmation dialog for large fetch');
            const confirmed = confirm(
                `You are about to fetch ${totalEditsToFetch} contributions.\n\n` +
                `This will take a significant amount of time and make many API calls.\n\n` +
                `Do you want to continue?`
            );
            if (!confirmed) {
                debug('User cancelled large fetch');
                abortController = null;
                return;
            }
            debug('User confirmed large fetch');
        }

        $('#fetch-btn').prop('disabled', true);
        $('#abort-btn').show();
        $('#results-section').empty();
        $('#progress-section').show();
        apiCallCount = 0;

        try {
            const allContributions = [];

            for (let i = 0; i < usernames.length; i++) {
                const username = usernames[i];
                const userColor = USER_COLORS[i];
                const editCount = editCounts[i];

                debug(`Processing user ${i + 1}/${usernames.length}:`, username);
                updateStatus(`Edit count for ${username}: ${editCount}`);

                const actualLimit = limit === 0 ? null : limit;

                updateStatus(`Fetching contributions for ${username}...`);
                const contributions = await getUserContributions(username, actualLimit, startTimestamp, endTimestamp, editCount);
                
                debug(`Fetched ${contributions.length} contributions for ${username}`);

                // Add username and color to each contribution
                contributions.forEach(contrib => {
                    contrib.username = username;
                    contrib.userColor = userColor;
                });

                allContributions.push(...contributions);
                updateStatus(`Fetched ${contributions.length} contributions for ${username}`);
            }

            debug(`Total contributions fetched: ${allContributions.length}`);

            // Sort all contributions by timestamp (newest first)
            allContributions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
            debug('Contributions sorted by timestamp');

            updateStatus('Fetching diff content...');
            await fetchDiffContent(allContributions);

            debug('All diffs fetched, displaying results');
            displayResults(allContributions, usernames);
            updateStatus(`Complete! Total API calls: ${apiCallCount}`);
            debug('Fetch complete. Total API calls:', apiCallCount);

        } catch (error) {
            if (error.name === 'AbortError') {
                updateStatus('Fetch aborted by user');
                debug('Fetch aborted by user');
            } else {
                updateStatus(`Error: ${error.message}`);
                debug('Error during fetch:', error);
                console.error(error);
            }
        } finally {
            $('#abort-btn').hide();
            $('#fetch-btn').prop('disabled', false);
            debug('Fetch process ended');
        }
    }

    async function getEditCountFromAPI(username) {
        debug('getEditCountFromAPI called for:', username);
        const params = {
            action: 'query',
            list: 'users',
            ususers: username,
            usprop: 'editcount',
            format: 'json',
            formatversion: 2
        };

        const queryString = Object.keys(params)
            .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
            .join('&');
        const url = `${API_ENDPOINT}?${queryString}`;
        
        debug('Fetching edit count from:', url);

        for (let retry = 0; retry < MAX_RETRIES; retry++) {
            try {
                const response = await fetch(url, {
                    method: 'GET',
                    headers: {
                        'Accept': 'application/json',
                        'Api-User-Agent': USER_AGENT
                    },
                    signal: abortController.signal
                });

                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }

                const data = await response.json();
                debug('Edit count API response:', data);

                if (data.error) {
                    throw new Error(data.error.info);
                }

                if (!data.query || !data.query.users || !data.query.users[0]) {
                    throw new Error('User not found');
                }

                const editCount = data.query.users[0].editcount;
                if (typeof editCount !== 'number') {
                    throw new Error('Could not retrieve edit count');
                }

                debug(`Edit count retrieved: ${editCount}`);
                return editCount;
            } catch (error) {
                debug(`Edit count API error (attempt ${retry + 1}):`, error);
                if (error.name === 'AbortError') {
                    throw error;
                }
                if (retry < MAX_RETRIES - 1) {
                    const delay = RETRY_DELAYS[retry];
                    updateStatus(`API call failed (attempt ${retry + 1}/${MAX_RETRIES}). Retrying in ${delay / 1000} seconds...`);
                    await sleep(delay);
                } else {
                    throw new Error(`API failed after ${MAX_RETRIES} retries: ${error.message}`);
                }
            }
        }
    }

    async function getUserContributions(username, limit, startTimestamp, endTimestamp, editCount) {
        debug('getUserContributions called:', { username, limit, startTimestamp, endTimestamp, editCount });
        const params = {
            action: 'query',
            list: 'usercontribs',
            ucuser: username,
            ucprop: 'ids|title|timestamp|comment',
            uclimit: 500,
            format: 'json',
            formatversion: 2,
            maxlag: 5
        };

        if (startTimestamp) params.ucend = startTimestamp;
        if (endTimestamp) params.ucstart = endTimestamp;

        debug('API params:', params);

        const contributions = [];
        let continueParam = null;

        while (true) {
            if (abortController.signal.aborted) {
                throw new DOMException('Aborted', 'AbortError');
            }

            const currentParams = { ...params };
            if (continueParam) {
                currentParams.uccontinue = continueParam;
            }

            debug('Fetching batch with continue:', continueParam);
            const response = await fetchWithRetry(API_ENDPOINT, currentParams);
            const data = JSON.parse(response);

            if (data.error) {
                throw new Error(data.error.info);
            }

            const batch = data.query.usercontribs || [];
            debug(`Received batch of ${batch.length} contributions`);
            contributions.push(...batch);

            // Show progress using limit if set, otherwise use editCount
            const progressTotal = (limit !== null && limit > 0) ? limit : editCount;
            if (progressTotal > 0) {
                updateProgress(contributions.length, progressTotal, `Fetching ${username}`);
            }

            if (!data.continue || (limit !== null && contributions.length >= limit)) {
                debug('No more contributions or limit reached');
                break;
            }

            continueParam = data.continue.uccontinue;
        }

        const finalContributions = limit !== null && limit > 0 ? contributions.slice(0, limit) : contributions;
        debug(`Returning ${finalContributions.length} contributions for ${username}`);
        return finalContributions;
    }

    async function fetchDiffContent(entries) {
        debug(`fetchDiffContent called for ${entries.length} entries`);
        const total = entries.length;
        for (let i = 0; i < entries.length; i++) {
            if (abortController.signal.aborted) {
                throw new DOMException('Aborted', 'AbortError');
            }

            const entry = entries[i];
            debug(`Fetching diff ${i + 1}/${total} for revid:`, entry.revid);
            try {
                const params = {
                    action: 'compare',
                    fromrev: entry.revid,
                    torelative: 'prev',
                    format: 'json',
                    formatversion: 2,
                    maxlag: 5
                };

                const response = await fetchWithRetry(API_ENDPOINT, params);
                const data = JSON.parse(response);

                if (data.error) {
                    entry.diffContent = `Error: ${data.error.info}`;
                    debug('Diff fetch error:', data.error.info);
                } else if (data.compare && data.compare.body) {
                    entry.diffContent = data.compare.body;
                    debug('Diff content retrieved successfully');
                } else {
                    entry.diffContent = 'No diff available';
                    debug('No diff available');
                }
            } catch (error) {
                entry.diffContent = `Error: ${error.message}`;
                debug('Diff fetch exception:', error);
            }

            updateProgress(i + 1, total, 'Fetching diffs');
        }
        debug('All diffs fetched');
    }

    async function fetchWithRetry(url, params) {
        const queryString = Object.keys(params)
            .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
            .join('&');
        const fullUrl = `${url}?${queryString}`;

        debug('fetchWithRetry:', fullUrl);

        for (let retry = 0; retry < MAX_RETRIES; retry++) {
            try {
                await sleep(API_DELAY);
                const response = await makeApiCall(fullUrl);
                const data = JSON.parse(response);
                
                // Check for maxlag error
                if (data.error && data.error.code === 'maxlag') {
                    const lagSeconds = data.error.lag || 5;
                    const waitTime = Math.min(lagSeconds * 1000, RETRY_DELAYS[retry]);
                    debug(`Database lag detected: ${lagSeconds}s, waiting ${waitTime}ms`);
                    updateStatus(`Database lag detected (${lagSeconds}s). Waiting ${waitTime / 1000}s before retry...`);
                    await sleep(waitTime);
                    continue;
                }
                
                debug('API call successful');
                return response;
            } catch (error) {
                debug(`API call error (attempt ${retry + 1}):`, error);
                if (error.name === 'AbortError') {
                    throw error;
                }
                if (retry < MAX_RETRIES - 1) {
                    const delay = RETRY_DELAYS[retry];
                    updateStatus(`API call failed (attempt ${retry + 1}/${MAX_RETRIES}). Retrying in ${delay / 1000} seconds...`);
                    await sleep(delay);
                } else {
                    throw new Error(`API call failed after ${MAX_RETRIES} retries: ${error.message}`);
                }
            }
        }
    }

    async function makeApiCall(url) {
        apiCallCount++;
        debug(`Making API call #${apiCallCount}:`, url);
        const response = await fetch(url, {
            method: 'GET',
            headers: {
                'Accept': 'application/json',
                'Api-User-Agent': USER_AGENT
            },
            signal: abortController.signal
        });

        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }

        return await response.text();
    }

    function sleep(ms) {
        debug(`Sleeping for ${ms}ms`);
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function updateStatus(message) {
        debug('Status update:', message);
        $('#status-section').text(message);
    }

    function updateProgress(current, total, label = 'Progress') {
        const percentage = Math.round((current / total) * 100);
        $('#progress-bar').css('width', `${percentage}%`);
        $('#progress-text').text(`${label}: ${current}/${total} (${percentage}%)`);
        debug(`Progress: ${label} ${current}/${total} (${percentage}%)`);
    }

    // Namespace definitions
    const NAMESPACES = [
        { value: 'article', label: 'Article (Main)' },
        { value: 'talk', label: 'Talk' },
        { value: 'user', label: 'User' },
        { value: 'user_talk', label: 'User Talk' },
        { value: 'wikipedia', label: 'Wikipedia' },
        { value: 'wikipedia_talk', label: 'Wikipedia Talk' },
        { value: 'file', label: 'File' },
        { value: 'file_talk', label: 'File Talk' },
        { value: 'mediawiki', label: 'MediaWiki' },
        { value: 'mediawiki_talk', label: 'MediaWiki Talk' },
        { value: 'template', label: 'Template' },
        { value: 'template_talk', label: 'Template Talk' },
        { value: 'help', label: 'Help' },
        { value: 'help_talk', label: 'Help Talk' },
        { value: 'category', label: 'Category' },
        { value: 'category_talk', label: 'Category Talk' },
        { value: 'portal', label: 'Portal' },
        { value: 'portal_talk', label: 'Portal Talk' },
        { value: 'draft', label: 'Draft' },
        { value: 'draft_talk', label: 'Draft Talk' },
        { value: 'timedtext', label: 'TimedText' },
        { value: 'timedtext_talk', label: 'TimedText Talk' },
        { value: 'module', label: 'Module' },
        { value: 'module_talk', label: 'Module Talk' }
    ];

    function getNamespace(articleName) {
        if (articleName.startsWith('Talk:')) return 'talk';
        if (articleName.startsWith('User talk:')) return 'user_talk';
        if (articleName.startsWith('User:')) return 'user';
        if (articleName.startsWith('Wikipedia talk:')) return 'wikipedia_talk';
        if (articleName.startsWith('Wikipedia:')) return 'wikipedia';
        if (articleName.startsWith('File talk:')) return 'file_talk';
        if (articleName.startsWith('File:')) return 'file';
        if (articleName.startsWith('MediaWiki talk:')) return 'mediawiki_talk';
        if (articleName.startsWith('MediaWiki:')) return 'mediawiki';
        if (articleName.startsWith('Template talk:')) return 'template_talk';
        if (articleName.startsWith('Template:')) return 'template';
        if (articleName.startsWith('Help talk:')) return 'help_talk';
        if (articleName.startsWith('Help:')) return 'help';
        if (articleName.startsWith('Category talk:')) return 'category_talk';
        if (articleName.startsWith('Category:')) return 'category';
        if (articleName.startsWith('Portal talk:')) return 'portal_talk';
        if (articleName.startsWith('Portal:')) return 'portal';
        if (articleName.startsWith('Draft talk:')) return 'draft_talk';
        if (articleName.startsWith('Draft:')) return 'draft';
        if (articleName.startsWith('TimedText talk:')) return 'timedtext_talk';
        if (articleName.startsWith('TimedText:')) return 'timedtext';
        if (articleName.startsWith('Module talk:')) return 'module_talk';
        if (articleName.startsWith('Module:')) return 'module';
        return 'article';
    }

    // Filter state management
    const FilterState = {
        selectedNamespaces: new Set(),
        selectedArticles: new Set(),
        
        init() {
            // Load from localStorage
            NAMESPACES.forEach(ns => {
                const stored = localStorage.getItem(`namespace_${ns.value}`);
                if (stored === null || stored === 'true') {
                    this.selectedNamespaces.add(ns.value);
                }
            });
        },
        
        toggleNamespace(namespace, checked) {
            if (checked) {
                this.selectedNamespaces.add(namespace);
            } else {
                this.selectedNamespaces.delete(namespace);
            }
            localStorage.setItem(`namespace_${namespace}`, checked.toString());
        },
        
        toggleArticle(article, checked) {
            if (checked) {
                this.selectedArticles.add(article);
            } else {
                this.selectedArticles.delete(article);
            }
            localStorage.setItem(`article_${article}`, checked.toString());
        },
        
        isNamespaceSelected(namespace) {
            return this.selectedNamespaces.has(namespace);
        },
        
        isArticleSelected(article) {
            return this.selectedArticles.has(article);
        },
        
        setAllNamespaces(checked) {
            NAMESPACES.forEach(ns => {
                if (checked) {
                    this.selectedNamespaces.add(ns.value);
                } else {
                    this.selectedNamespaces.delete(ns.value);
                }
                localStorage.setItem(`namespace_${ns.value}`, checked.toString());
            });
        },
        
        setAllArticles(articles, checked) {
            articles.forEach(article => {
                if (checked) {
                    this.selectedArticles.add(article);
                } else {
                    this.selectedArticles.delete(article);
                }
                localStorage.setItem(`article_${article}`, checked.toString());
            });
        }
    };

    function displayResults(contributions, usernames) {
        debug('displayResults called with', contributions.length, 'contributions for', usernames.length, 'users');
        const wikiServer = mw.config.get('wgServer');
        const wikiScriptPath = mw.config.get('wgScriptPath');
        
        // Initialize filter state
        FilterState.init();
        
        // Store original order
        originalOrder = contributions.map((entry, index) => ({
            articleName: entry.title,
            timestamp: entry.timestamp,
            index: index,
            element: null // Will be set after row creation
        }));
        
        // Pre-calculate namespace counts
        const namespaceCounts = {};
        NAMESPACES.forEach(ns => namespaceCounts[ns.value] = 0);
        contributions.forEach(entry => {
            const namespace = getNamespace(entry.title);
            namespaceCounts[namespace]++;
        });
        
        // Get unique articles
        const articlesSet = new Set();
        contributions.forEach(entry => articlesSet.add(entry.title));
        const articles = Array.from(articlesSet).sort();
        
        // Initialize article filter state from localStorage
        articles.forEach(article => {
            const stored = localStorage.getItem(`article_${article}`);
            if (stored === null || stored === 'true') {
                FilterState.selectedArticles.add(article);
            }
        });
        
        // Create results container
        const resultsSection = document.getElementById('results-section');
        resultsSection.innerHTML = '';
        
        // Add styles
        const styleEl = document.createElement('style');
        styleEl.textContent = `
            .diff-viewer-table {
                width: 100%;
                border-collapse: collapse;
                margin-top: 20px;
            }
            .diff-viewer-table th,
            .diff-viewer-table td {
                border: 1px solid #ddd;
                padding: 8px;
                text-align: left;
            }
            .diff-viewer-table th {
                background-color: #f2f2f2;
                font-weight: bold;
            }
            .diff-viewer-table tbody tr:nth-child(even):not(.pinned-row) {
                background-color: #f9f9f9;
            }
            .metadata-cell {
                width: 250px;
                vertical-align: top;
            }
            .sticky-wrapper {
                position: sticky;
                top: 0;
            }
            .diff-cell {
                max-width: 800px;
                overflow-x: auto;
            }
            .edit-summary {
                background-color: #f8f9fa;
                padding: 8px;
                margin-bottom: 10px;
                border-left: 3px solid #0645ad;
                font-style: italic;
                color: #333;
            }
            .edit-summary-empty {
                color: #999;
            }
            .diff-context {
                background-color: #f8f9fa;
            }
            .diff-addedline {
                background-color: #d8f6d8;
            }
            .diff-deletedline {
                background-color: #fee;
            }
            .toggle-context-btn {
                margin: 10px 0;
                padding: 5px 10px;
                cursor: pointer;
            }
            .hidden {
                display: none !important;
            }
            .user-legend {
                display: flex;
                gap: 20px;
                margin: 15px 0;
                padding: 10px;
                background-color: #f8f9fa;
                border-radius: 5px;
            }
            .user-legend-item {
                display: flex;
                align-items: center;
                gap: 8px;
            }
            .user-legend-color {
                width: 20px;
                height: 20px;
                border: 2px solid #000;
                display: inline-block;
            }
            .row-controls {
                display: flex;
                flex-direction: column;
                gap: 5px;
                margin-top: 5px;
            }
            .pin-button, .collapse-button {
                padding: 2px 8px;
                border: 1px solid #ddd;
                border-radius: 3px;
                cursor: pointer;
                font-size: 12px;
                background: #f8f8f8;
                width: 100%;
            }
            .pin-button:hover, .collapse-button:hover {
                background: #eee;
            }
            .pinned-row {
                background-color: #fff8dc !important;
            }
            .pin-button.active {
                background-color: #ffd700;
                border-color: #daa520;
            }
            .collapsed-row td:nth-child(2) {
                display: none;
            }
            .collapsed-row td:first-child {
                border-right: none;
            }
            .collapsed-row .pin-button {
                display: none;
            }
            .filter-controls {
                margin: 20px 0;
                padding: 15px;
                border: 1px solid #ddd;
                border-radius: 5px;
                background-color: #f9f9f9;
            }
            .namespace-item {
                margin-bottom: 5px;
                white-space: nowrap;
            }
            .namespace-count {
                color: #666;
                font-size: 0.9em;
            }
            .namespace-checkbox:disabled + span {
                color: #999;
                cursor: not-allowed;
            }
            .filter-hidden {
                display: none !important;
            }
        `;
        document.head.appendChild(styleEl);
        
        // Create title
        const userCount = usernames.length;
        const title = userCount === 1 
            ? `Contributions for ${usernames[0]}` 
            : `Contributions for ${userCount} users`;
        
        const titleEl = document.createElement('h3');
        titleEl.textContent = `${title} (${contributions.length} entries total)`;
        resultsSection.appendChild(titleEl);
        
        // Add user legend if multiple users
        if (userCount > 1) {
            const legendDiv = document.createElement('div');
            legendDiv.className = 'user-legend';
            usernames.forEach((username, index) => {
                const item = document.createElement('div');
                item.className = 'user-legend-item';
                const colorBox = document.createElement('span');
                colorBox.className = 'user-legend-color';
                colorBox.style.backgroundColor = USER_COLORS[index];
                const nameSpan = document.createElement('span');
                nameSpan.textContent = username;
                item.appendChild(colorBox);
                item.appendChild(nameSpan);
                legendDiv.appendChild(item);
            });
            resultsSection.appendChild(legendDiv);
        }
        
        // Create filter controls
        const filterDiv = document.createElement('div');
        filterDiv.className = 'filter-controls';
        
        const filterCounter = document.createElement('div');
        filterCounter.id = 'filterCounter';
        filterCounter.style.marginBottom = '15px';
        filterCounter.style.fontWeight = 'bold';
        filterDiv.appendChild(filterCounter);
        
        // Namespace filter
        const nsDiv = document.createElement('div');
        nsDiv.style.marginBottom = '15px';
        
        const nsControlsDiv = document.createElement('div');
        nsControlsDiv.style.marginBottom = '10px';
        nsControlsDiv.innerHTML = '<strong>Namespace Filter:</strong>';
        
        const checkAllNsBtn = document.createElement('button');
        checkAllNsBtn.textContent = 'Check All';
        checkAllNsBtn.style.marginLeft = '10px';
        checkAllNsBtn.onclick = () => {
            FilterState.setAllNamespaces(true);
            document.querySelectorAll('.namespace-checkbox:not(:disabled)').forEach(cb => cb.checked = true);
            applyFilters();
        };
        
        const checkNoneNsBtn = document.createElement('button');
        checkNoneNsBtn.textContent = 'Check None';
        checkNoneNsBtn.style.marginLeft = '5px';
        checkNoneNsBtn.onclick = () => {
            FilterState.setAllNamespaces(false);
            document.querySelectorAll('.namespace-checkbox:not(:disabled)').forEach(cb => cb.checked = false);
            applyFilters();
        };
        
        nsControlsDiv.appendChild(checkAllNsBtn);
        nsControlsDiv.appendChild(checkNoneNsBtn);
        nsDiv.appendChild(nsControlsDiv);
        
        // Create namespace grid
        const nsGrid = document.createElement('div');
        nsGrid.style.display = 'grid';
        nsGrid.style.gridTemplateColumns = 'repeat(4, 1fr)';
        nsGrid.style.gap = '10px';
        
        const nsItemsPerColumn = Math.ceil(NAMESPACES.length / 4);
        for (let col = 0; col < 4; col++) {
            const columnDiv = document.createElement('div');
            columnDiv.style.display = 'flex';
            columnDiv.style.flexDirection = 'column';
            columnDiv.style.gap = '5px';
            
            for (let i = 0; i < nsItemsPerColumn; i++) {
                const nsIndex = col * nsItemsPerColumn + i;
                if (nsIndex >= NAMESPACES.length) break;
                
                const ns = NAMESPACES[nsIndex];
                const count = namespaceCounts[ns.value] || 0;
                const isChecked = FilterState.isNamespaceSelected(ns.value);
                const isDisabled = count === 0;
                
                const itemDiv = document.createElement('div');
                itemDiv.className = 'namespace-item';
                
                const label = document.createElement('label');
                const checkbox = document.createElement('input');
                checkbox.type = 'checkbox';
                checkbox.className = 'namespace-checkbox';
                checkbox.value = ns.value;
                checkbox.checked = isChecked;
                checkbox.disabled = isDisabled;
                checkbox.onchange = (e) => {
                    FilterState.toggleNamespace(ns.value, e.target.checked);
                    applyFilters();
                };
                
                const span = document.createElement('span');
                span.innerHTML = `${ns.label} <span class="namespace-count">(${count})</span>`;
                
                label.appendChild(checkbox);
                label.appendChild(document.createTextNode(' '));
                label.appendChild(span);
                itemDiv.appendChild(label);
                columnDiv.appendChild(itemDiv);
            }
            nsGrid.appendChild(columnDiv);
        }
        nsDiv.appendChild(nsGrid);
        filterDiv.appendChild(nsDiv);
        
        // Article filter
        const artDiv = document.createElement('div');
        
        const artControlsDiv = document.createElement('div');
        artControlsDiv.style.marginBottom = '10px';
        artControlsDiv.innerHTML = '<strong>Article Filter:</strong>';
        
        const checkAllArtBtn = document.createElement('button');
        checkAllArtBtn.textContent = 'Check All';
        checkAllArtBtn.style.marginLeft = '10px';
        checkAllArtBtn.onclick = () => {
            FilterState.setAllArticles(articles, true);
            document.querySelectorAll('.article-checkbox').forEach(cb => cb.checked = true);
            applyFilters();
        };
        
        const checkNoneArtBtn = document.createElement('button');
        checkNoneArtBtn.textContent = 'Check None';
        checkNoneArtBtn.style.marginLeft = '5px';
        checkNoneArtBtn.onclick = () => {
            FilterState.setAllArticles(articles, false);
            document.querySelectorAll('.article-checkbox').forEach(cb => cb.checked = false);
            applyFilters();
        };
        
        artControlsDiv.appendChild(checkAllArtBtn);
        artControlsDiv.appendChild(checkNoneArtBtn);
        artDiv.appendChild(artControlsDiv);
        
        const artList = document.createElement('div');
        artList.id = 'articleList';
        artList.style.minHeight = '100px';
        artList.style.height = '300px';
        artList.style.maxHeight = '80vh';
        artList.style.overflowY = 'auto';
        artList.style.border = '1px solid #eee';
        artList.style.padding = '10px';
        artList.style.resize = 'vertical';
        
        const artGrid = document.createElement('div');
        artGrid.style.display = 'grid';
        artGrid.style.gridTemplateColumns = 'repeat(4, 1fr)';
        artGrid.style.gap = '10px';
        
        const artItemsPerColumn = Math.ceil(articles.length / 4);
        for (let col = 0; col < 4; col++) {
            const columnDiv = document.createElement('div');
            columnDiv.style.display = 'flex';
            columnDiv.style.flexDirection = 'column';
            columnDiv.style.gap = '5px';
            
            for (let i = 0; i < artItemsPerColumn; i++) {
                const artIndex = col * artItemsPerColumn + i;
                if (artIndex >= articles.length) break;
                
                const article = articles[artIndex];
                const namespace = getNamespace(article);
                const isChecked = FilterState.isArticleSelected(article);
                
                const itemDiv = document.createElement('div');
                itemDiv.className = 'article-checkbox-container';
                itemDiv.setAttribute('data-namespace', namespace);
                itemDiv.style.whiteSpace = 'nowrap';
                itemDiv.style.overflow = 'hidden';
                itemDiv.style.textOverflow = 'ellipsis';
                
                const label = document.createElement('label');
                label.title = article;
                
                const checkbox = document.createElement('input');
                checkbox.type = 'checkbox';
                checkbox.className = 'article-checkbox';
                checkbox.value = article;
                checkbox.checked = isChecked;
                checkbox.onchange = (e) => {
                    FilterState.toggleArticle(article, e.target.checked);
                    applyFilters();
                };
                
                label.appendChild(checkbox);
                label.appendChild(document.createTextNode(` ${article}`));
                itemDiv.appendChild(label);
                columnDiv.appendChild(itemDiv);
            }
            artGrid.appendChild(columnDiv);
        }
        artList.appendChild(artGrid);
        artDiv.appendChild(artList);
        filterDiv.appendChild(artDiv);
        resultsSection.appendChild(filterDiv);
        
        // Context toggle button
        const toggleBtn = document.createElement('button');
        toggleBtn.className = 'toggle-context-btn';
        toggleBtn.innerHTML = '<span id="icon">👁️</span> <span id="text">Hide Context</span>';
        toggleBtn.onclick = () => {
            const contextElements = document.querySelectorAll('.diff-context');
            const iconSpan = toggleBtn.querySelector('#icon');
            const textSpan = toggleBtn.querySelector('#text');
            const isVisible = contextElements.length > 0 && !contextElements[0].classList.contains('hidden');
            
            contextElements.forEach(element => element.classList.toggle('hidden'));
            
            if (isVisible) {
                iconSpan.textContent = '👁️‍🗨️';
                textSpan.textContent = 'Show Context';
            } else {
                iconSpan.textContent = '👁️';
                textSpan.textContent = 'Hide Context';
            }
        };
        resultsSection.appendChild(toggleBtn);
        
        // Create table
        const table = document.createElement('table');
        table.className = 'diff-viewer-table wikitable';
        
        const thead = document.createElement('thead');
        thead.innerHTML = '<tr><th>Metadata</th><th>Diff</th></tr>';
        table.appendChild(thead);
        
        const tbody = document.createElement('tbody');
        
        // Create rows
        contributions.forEach((entry, index) => {
            const cleanedDiff = cleanupDiffContent(entry.diffContent || '');
            const wrappedDiff = `<table style="width:100%;"><tr><td style="width:50%;"></td><td style="width:50%;"></td></tr>${cleanedDiff}</table>`;
            
            const row = document.createElement('tr');
            row.className = 'user-row';
            const rowId = `row_${index}`;
            row.setAttribute('data-row-id', rowId);
            row.setAttribute('data-article', entry.title);
            row.setAttribute('data-namespace', getNamespace(entry.title));
            
            if (userCount > 1) {
                row.style.borderLeft = `5px solid ${entry.userColor}`;
            }
            
            // Metadata cell
            const metaCell = document.createElement('td');
            metaCell.className = 'metadata-cell';
            
            const stickyWrapper = document.createElement('div');
            stickyWrapper.className = 'sticky-wrapper';
            
            if (userCount > 1) {
                const userP = document.createElement('p');
                const userStrong = document.createElement('strong');
                userStrong.textContent = entry.username;
                userP.appendChild(userStrong);
                stickyWrapper.appendChild(userP);
            }
            
            const titleP = document.createElement('p');
            titleP.textContent = entry.title;
            stickyWrapper.appendChild(titleP);
            
            const linkP = document.createElement('p');
            const link = document.createElement('a');
            link.href = `${wikiServer}${wikiScriptPath}/index.php?diff=${entry.revid}`;
            link.target = '_blank';
            link.textContent = 'diff';
            linkP.appendChild(link);
            stickyWrapper.appendChild(linkP);
            
            const timestampP = document.createElement('p');
            timestampP.textContent = entry.timestamp;
            stickyWrapper.appendChild(timestampP);
            
            // Row controls
            const controlsDiv = document.createElement('div');
            controlsDiv.className = 'row-controls';
            
            const pinBtn = document.createElement('button');
            pinBtn.className = 'pin-button';
            pinBtn.textContent = '[PIN]';
            pinBtn.setAttribute('data-row-id', rowId);
            
            const collapseBtn = document.createElement('button');
            collapseBtn.className = 'collapse-button';
            collapseBtn.textContent = '[COLLAPSE]';
            collapseBtn.setAttribute('data-row-id', rowId);
            
            controlsDiv.appendChild(pinBtn);
            controlsDiv.appendChild(collapseBtn);
            stickyWrapper.appendChild(controlsDiv);
            
            metaCell.appendChild(stickyWrapper);
            row.appendChild(metaCell);
            
            // Diff cell with edit summary
            const diffCell = document.createElement('td');
            diffCell.className = 'diff-cell';
            
            // Add edit summary at the top
            const summaryDiv = document.createElement('div');
            summaryDiv.className = 'edit-summary';
            if (entry.comment && entry.comment.trim()) {
                summaryDiv.textContent = entry.comment;
            } else {
                summaryDiv.className = 'edit-summary edit-summary-empty';
                summaryDiv.textContent = '(no edit summary)';
            }
            
            diffCell.appendChild(summaryDiv);
            
            // Add diff content below
            const diffContentDiv = document.createElement('div');
            diffContentDiv.innerHTML = wrappedDiff;
            diffCell.appendChild(diffContentDiv);
            
            row.appendChild(diffCell);
            
            tbody.appendChild(row);
            
            // Store reference in originalOrder
            originalOrder[index].element = row;
            
            // Restore pinned/collapsed state
            if (localStorage.getItem(`pinned_${rowId}`) === 'true') {
                row.classList.add('pinned-row');
                pinBtn.classList.add('active');
                pinBtn.textContent = '[PINNED]';
            }
            if (localStorage.getItem(`collapsed_${rowId}`) === 'true') {
                row.classList.add('collapsed-row');
                collapseBtn.textContent = '[EXPAND]';
            }
        });
        
        table.appendChild(tbody);
        resultsSection.appendChild(table);
        
        // Event delegation for buttons
        tbody.addEventListener('click', (e) => {
            const target = e.target;
            if (target.classList.contains('pin-button')) {
                const rowId = target.getAttribute('data-row-id');
                const row = tbody.querySelector(`[data-row-id="${rowId}"]`);
                togglePin(row, target, rowId);
            } else if (target.classList.contains('collapse-button')) {
                const rowId = target.getAttribute('data-row-id');
                const row = tbody.querySelector(`[data-row-id="${rowId}"]`);
                toggleCollapse(row, target, rowId);
            }
        });
        
        // Sort pinned rows
        sortPinnedRows();
        
        // Apply initial filters
        applyFilters();
    }

    function applyFilters() {
        let visibleCount = 0;
        const rows = document.querySelectorAll('.user-row');
        const totalCount = rows.length;
        
        rows.forEach(row => {
            const articleName = row.getAttribute('data-article');
            const namespace = row.getAttribute('data-namespace');
            
            const namespaceMatch = FilterState.isNamespaceSelected(namespace);
            const articleMatch = FilterState.isArticleSelected(articleName);
            const isVisible = namespaceMatch && articleMatch;
            
            row.classList.toggle('filter-hidden', !isVisible);
            if (isVisible) visibleCount++;
        });
        
        // Filter article checkboxes
        document.querySelectorAll('.article-checkbox-container').forEach(container => {
            const namespace = container.getAttribute('data-namespace');
            container.classList.toggle('filter-hidden', !FilterState.isNamespaceSelected(namespace));
        });
        
        document.getElementById('filterCounter').textContent = 
            `Showing ${visibleCount} out of ${totalCount} entries`;
    }

    function togglePin(row, button, rowId) {
        const isPinned = row.classList.toggle('pinned-row');
        button.classList.toggle('active');
        button.textContent = isPinned ? '[PINNED]' : '[PIN]';
        localStorage.setItem(`pinned_${rowId}`, isPinned.toString());
        sortPinnedRows();
    }

    function toggleCollapse(row, button, rowId) {
        const isCollapsed = row.classList.toggle('collapsed-row');
        button.textContent = isCollapsed ? '[EXPAND]' : '[COLLAPSE]';
        localStorage.setItem(`collapsed_${rowId}`, isCollapsed.toString());
    }

    function sortPinnedRows() {
        const tbody = document.querySelector('.diff-viewer-table tbody');
        if (!tbody) return;
        
        const pinnedRows = [];
        const unpinnedRows = [];
        
        originalOrder.forEach(item => {
            const row = item.element;
            if (row && row.classList.contains('pinned-row')) {
                pinnedRows.push(row);
            } else if (row) {
                unpinnedRows.push(row);
            }
        });
        
        const fragment = document.createDocumentFragment();
        pinnedRows.forEach(row => fragment.appendChild(row));
        unpinnedRows.forEach(row => fragment.appendChild(row));
        
        tbody.innerHTML = '';
        tbody.appendChild(fragment);
    }

    function cleanupDiffContent(content) {
        if (!content) return '';

        let cleaned = content;

        // Remove moved paragraph markers
        cleaned = cleaned.replace(/<a class="mw-diff-movedpara[^>]*>.*?<\/a>|<a name="movedpara_[^"]*"><\/a>/g, '');

        // Remove colspan attributes
        cleaned = cleaned.replace(/colspan="\d+"/g, '');

        // Normalize br tags
        cleaned = cleaned.replace(/<br \/>/g, '<br>');

        // Remove empty diff-marker cells
        cleaned = cleaned.replace(/<td\s+class\s*=\s*"diff-marker"\s*(?:data-marker\s*=\s*"[+\-−]")?\s*>\s*<\/td>/g, '');

        // Add inline styles for ins tags
        cleaned = cleaned.replace(
            /<ins class="diffchange diffchange-inline">/g,
            '<ins class="diffchange diffchange-inline" style="text-decoration: none; font-weight: bold; background-color:#a3d3ff; color:#202122; border-radius: 0.33em; padding: 0.25em 0;">'
        );

        // Add inline styles for del tags
        cleaned = cleaned.replace(
            /<del class="diffchange diffchange-inline">/g,
            '<del class="diffchange diffchange-inline" style="text-decoration: none; font-weight: bold; background-color:#f8ddc3; color:#202122; border-radius: 0.33em; padding: 0.25em 0;">'
        );

        return cleaned;
    }

})();
// </nowiki>