User:Polygnotus/Scripts/DiffViewer.js

// <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>

Content Disclaimer

Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.

  1. The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
  2. There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
  3. It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
  4. Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.