Jump to content

User:Polygnotus/Scripts/RevisionSearch.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>
// RevisionSearch - Special:BlankPage/RevisionSearch
(function() {
    'use strict';
    
    // ===== CONFIGURATION =====
    var CONFIG = {
        MAX_REVISIONS_WARNING: 5000,     // Warn if page has more than this many revisions
        DELAY_MS: 1000,                  // Delay between API requests in milliseconds
        BATCH_SIZE: 500,                 // Revisions per request (500 for bots, 50 for regular users)
    };
    // ===== END CONFIGURATION =====
    
    // Add link to tools menu on all pages
    mw.util.addPortletLink(
        'p-tb',
        '/wiki/Special:BlankPage/RevisionSearch',
        'RevisionSearch',
        't-revision-search',
        'Search for text additions/removals in page history'
    );
    
    // Only run the main interface on the special page
    if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' || 
        mw.config.get('wgPageName') !== 'Special:BlankPage/RevisionSearch') {
        return;
    }
    
    // Customize the page
    document.title = 'RevisionSearch';
    $('#firstHeading').text('RevisionSearch');
    
    // Create the interface
    const interfaceHTML = `
        <div style="max-width: 1200px;">
            <p>Search through a page's revision history to find when specific text was added or removed.</p>
            
            <div style="margin: 20px 0; padding: 15px; border: 1px solid #ccc; background: #f9f9f9;">
                <div style="margin-bottom: 15px;">
                    <label for="page-title" style="display: block; font-weight: bold; margin-bottom: 5px;">Page Title:</label>
                    <input type="text" id="page-title" style="width: 100%; padding: 5px; font-size: 14px;" placeholder="Enter page title (e.g., Main Page)">
                </div>
                
                <div style="margin-bottom: 15px;">
                    <label style="display: block; font-weight: bold; margin-bottom: 5px;">Search Type:</label>
                    <label style="margin-right: 20px;">
                        <input type="radio" name="search-type" value="string" checked> Plain Text
                    </label>
                    <label>
                        <input type="radio" name="search-type" value="regex"> Regular Expression
                    </label>
                </div>
                
                <div style="margin-bottom: 15px;">
                    <label for="search-pattern" style="display: block; font-weight: bold; margin-bottom: 5px;">Search Pattern:</label>
                    <input type="text" id="search-pattern" style="width: 100%; padding: 5px; font-size: 14px;" placeholder="Enter text or regex pattern">
                    <small style="color: #666;">For regex, enter without delimiters (e.g., \\[\\[Category:.*?\\]\\] for case-sensitive, add (?i) prefix for case-insensitive)</small>
                </div>
                
                <div style="margin-bottom: 15px;">
                    <label style="display: block; font-weight: bold; margin-bottom: 5px;">Search For:</label>
                    <label style="margin-right: 20px;">
                        <input type="radio" name="search-mode" value="inserted" checked> Text Insertion
                    </label>
                    <label style="margin-right: 20px;">
                        <input type="radio" name="search-mode" value="removed"> Text Removal
                    </label>
                    <label>
                        <input type="radio" name="search-mode" value="both"> Both
                    </label>
                </div>
                
                <div style="margin-bottom: 15px;">
                    <label style="display: block; font-weight: bold; margin-bottom: 5px;">Sort Results:</label>
                    <label style="margin-right: 20px;">
                        <input type="radio" name="sort-order" value="oldest" checked> Oldest First
                    </label>
                    <label>
                        <input type="radio" name="sort-order" value="newest"> Newest First
                    </label>
                </div>
                
                <button id="start-search" style="padding: 10px 20px; font-size: 14px; background: #36c; color: white; border: none; cursor: pointer; border-radius: 3px;">
                    Start Search
                </button>
                <button id="check-revisions" style="padding: 10px 20px; font-size: 14px; background: #999; color: white; border: none; cursor: pointer; border-radius: 3px; margin-left: 10px;">
                    Check Revision Count
                </button>
            </div>
            
            <div id="status-area" style="margin: 20px 0; padding: 15px; border: 1px solid #ccc; background: #f0f0f0; display: none;">
                <h3 style="margin-top: 0;">Status</h3>
                <div id="status-messages"></div>
            </div>
            
            <div id="results-area" style="margin: 20px 0; padding: 15px; border: 1px solid #ccc; background: #f0f8ff; display: none;">
                <h3 style="margin-top: 0;">Results</h3>
                <div id="results-content"></div>
                <div id="wikicode-area" style="margin-top: 20px; display: none;">
                    <h4>Wikicode Output</h4>
                    <button id="copy-wikicode" style="padding: 8px 16px; font-size: 14px; background: #36c; color: white; border: none; cursor: pointer; border-radius: 3px; margin-bottom: 10px;">
                        Copy to Clipboard
                    </button>
                    <textarea id="wikicode-output" readonly style="width: 100%; height: 400px; font-family: monospace; font-size: 12px; padding: 10px; border: 1px solid #ccc;"></textarea>
                </div>
            </div>
        </div>
    `;
    
    $('#mw-content-text').html(interfaceHTML);
    
    // Helper functions
    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    function showStatus(message, append = false) {
        $('#status-area').show();
        if (append) {
            $('#status-messages').append('<p style="margin: 5px 0;">' + message + '</p>');
        } else {
            $('#status-messages').html('<p style="margin: 5px 0;">' + message + '</p>');
        }
    }
    
    function showResults(html) {
        $('#results-area').show();
        $('#results-content').html(html);
    }
    
    function clearResults() {
        $('#results-area').hide();
        $('#results-content').html('');
        $('#wikicode-area').hide();
        $('#wikicode-output').val('');
    }
    
    // Copy wikicode to clipboard handler
    $(document).on('click', '#copy-wikicode', function() {
        const textarea = document.getElementById('wikicode-output');
        textarea.select();
        textarea.setSelectionRange(0, 99999); // For mobile devices
        
        try {
            document.execCommand('copy');
            $(this).text('Copied!').css('background', '#28a745');
            setTimeout(() => {
                $(this).text('Copy to Clipboard').css('background', '#36c');
            }, 2000);
        } catch (err) {
            alert('Failed to copy to clipboard. Please select and copy manually.');
        }
    });
    
    async function getRevisionCount(pageTitle) {
        const project = mw.config.get('wgServerName');
        const apiUrl = 'https://xtools.wmcloud.org/api/page/articleinfo/' + project + '/' + encodeURIComponent(pageTitle);
        
        try {
            const data = await $.getJSON(apiUrl);
            return data.revisions || 0;
        } catch (error) {
            console.error('XTools API request failed:', error);
            return null;
        }
    }
    
    function searchInContent(content, searchPattern, isRegex) {
        if (!content) return false;
        
        if (isRegex) {
            try {
                const regex = new RegExp(searchPattern);
                return regex.test(content);
            } catch (e) {
                console.error('Invalid regex:', e);
                return false;
            }
        } else {
            return content.includes(searchPattern);
        }
    }
    
    function analyzeRevisions(revisions, searchPattern, isRegex, searchMode) {
        const results = {
            insertions: [],
            removals: [],
            searchPattern: searchPattern,
            isRegex: isRegex
        };
        
        // Sort revisions by timestamp (oldest to newest)
        const sorted = [...revisions].sort((a, b) => 
            new Date(a.timestamp) - new Date(b.timestamp)
        );
        
        for (let i = 0; i < sorted.length; i++) {
            const current = sorted[i];
            const previous = i > 0 ? sorted[i - 1] : null;
            
            const currentHasMatch = searchInContent(current.content, searchPattern, isRegex);
            const previousHasMatch = previous ? searchInContent(previous.content, searchPattern, isRegex) : false;
            
            // Insertion: previous didn't have it, current does
            if (!previousHasMatch && currentHasMatch) {
                results.insertions.push({
                    revid: current.revid,
                    parentid: current.parentid,
                    timestamp: current.timestamp,
                    user: current.user,
                    comment: current.comment || ''
                });
            }
            
            // Removal: previous had it, current doesn't
            if (previousHasMatch && !currentHasMatch) {
                results.removals.push({
                    revid: current.revid,
                    parentid: current.parentid,
                    timestamp: current.timestamp,
                    user: current.user,
                    comment: current.comment || ''
                });
            }
        }
        
        return results;
    }
    
    async function fetchAllRevisions(pageTitle, progressCallback) {
        const api = new mw.Api({
            ajax: {
                headers: {
                    'Api-User-Agent': 'WikipediaRevisionSearch/1.0 (User:' + mw.config.get('wgUserName') + ')'
                }
            }
        });
        
        let allRevisions = [];
        let rvcontinue = undefined;
        let requestCount = 0;
        
        do {
            const params = {
                action: 'query',
                prop: 'revisions',
                titles: pageTitle,
                rvprop: 'ids|timestamp|user|userid|size|comment|content',
                rvlimit: CONFIG.BATCH_SIZE,
                rvslots: 'main',
                format: 'json',
                formatversion: 2
            };
            
            if (rvcontinue) {
                params.rvcontinue = rvcontinue;
            }
            
            requestCount++;
            if (progressCallback) {
                progressCallback(requestCount, allRevisions.length);
            }
            
            const response = await api.get(params);
            
            if (response.query && response.query.pages && response.query.pages[0]) {
                const page = response.query.pages[0];
                if (page.revisions) {
                    const processedRevisions = page.revisions.map(rev => {
                        const processed = { ...rev };
                        if (rev.slots && rev.slots.main) {
                            processed.content = rev.slots.main.content;
                            delete processed.slots;
                        }
                        return processed;
                    });
                    
                    allRevisions = allRevisions.concat(processedRevisions);
                }
            }
            
            rvcontinue = response.continue ? response.continue.rvcontinue : undefined;
            
            if (rvcontinue) {
                await sleep(CONFIG.DELAY_MS);
            }
            
        } while (rvcontinue);
        
        return allRevisions;
    }
    
    function formatRevisionLink(pageTitle, revid, parentid) {
        const diffUrl = 'https://' + mw.config.get('wgServerName') + '/w/index.php?title=' + 
                       encodeURIComponent(pageTitle) + '&diff=' + revid + '&oldid=' + parentid;
        const permUrl = 'https://' + mw.config.get('wgServerName') + '/w/index.php?oldid=' + revid;
        
        return '<a href="' + diffUrl + '" target="_blank">diff</a> | ' +
               '<a href="' + permUrl + '" target="_blank">permalink</a>';
    }
    
    function formatResultsTable(insertions, removals, pageTitle, sortNewest) {
        // Combine both arrays with type markers
        const combined = [
            ...insertions.map(rev => ({...rev, type: 'insertion'})),
            ...removals.map(rev => ({...rev, type: 'removal'}))
        ];
        
        if (combined.length === 0) {
            return '<p>No matches found.</p>';
        }
        
        // Sort by timestamp
        combined.sort((a, b) => {
            const timeA = new Date(a.timestamp).getTime();
            const timeB = new Date(b.timestamp).getTime();
            return sortNewest ? timeB - timeA : timeA - timeB;
        });
        
        let html = '<table style="width: 100%; border-collapse: collapse; margin-top: 10px;" class="sortable">';
        html += '<thead><tr style="background: #eee;">';
        html += '<th style="border: 1px solid #ccc; padding: 8px; text-align: left;">#</th>';
        html += '<th style="border: 1px solid #ccc; padding: 8px; text-align: left;">Type</th>';
        html += '<th style="border: 1px solid #ccc; padding: 8px; text-align: left;" class="sortable">Timestamp</th>';
        html += '<th style="border: 1px solid #ccc; padding: 8px; text-align: left;">User</th>';
        html += '<th style="border: 1px solid #ccc; padding: 8px; text-align: left;">Edit Summary</th>';
        html += '<th style="border: 1px solid #ccc; padding: 8px; text-align: left;">Links</th>';
        html += '</tr></thead><tbody>';
        
        combined.forEach((rev, index) => {
            const bgColor = rev.type === 'insertion' ? '#d4edda' : '#f8d7da';
            const typeLabel = rev.type === 'insertion' ? 'Insertion' : 'Removal';
            const timestamp = new Date(rev.timestamp);
            const sortKey = timestamp.toISOString();
            
            html += '<tr style="background: ' + bgColor + ';">';
            html += '<td style="border: 1px solid #ccc; padding: 8px;">' + (index + 1) + '</td>';
            html += '<td style="border: 1px solid #ccc; padding: 8px; font-weight: bold;">' + typeLabel + '</td>';
            html += '<td style="border: 1px solid #ccc; padding: 8px;" data-sort-value="' + sortKey + '">' + 
                   timestamp.toLocaleString() + '</td>';
            html += '<td style="border: 1px solid #ccc; padding: 8px;">' + 
                   mw.html.escape(rev.user) + '</td>';
            html += '<td style="border: 1px solid #ccc; padding: 8px;">' + 
                   mw.html.escape(rev.comment || '(no comment)') + '</td>';
            html += '<td style="border: 1px solid #ccc; padding: 8px;">' + 
                   formatRevisionLink(pageTitle, rev.revid, rev.parentid) + '</td>';
            html += '</tr>';
        });
        
        html += '</tbody></table>';
        
        // Load sortable table script if not already loaded
        if (typeof $.tablesorter === 'undefined') {
            mw.loader.using('jquery.tablesorter').then(function() {
                $('.sortable').tablesorter();
            });
        } else {
            $('.sortable').tablesorter();
        }
        
        return html;
    }
    
    function formatWikicode(insertions, removals, pageTitle, sortNewest, searchPattern, isRegex) {
        // Combine both arrays with type markers
        const combined = [
            ...insertions.map(rev => ({...rev, type: 'insertion'})),
            ...removals.map(rev => ({...rev, type: 'removal'}))
        ];
        
        if (combined.length === 0) {
            return 'No matches found.';
        }
        
        // Sort by timestamp
        combined.sort((a, b) => {
            const timeA = new Date(a.timestamp).getTime();
            const timeB = new Date(b.timestamp).getTime();
            return sortNewest ? timeB - timeA : timeA - timeB;
        });
        
        let wikicode = '== RevisionSearch ==\n\n';
        wikicode += "'''Search Pattern:''' " + (isRegex ? 'Regex: ' : 'Text: ') + '<code>' + searchPattern + '</code>\n\n';
        wikicode += "'''Total Insertions:''' " + insertions.length + '\n\n';
        wikicode += "'''Total Removals:''' " + removals.length + '\n\n';
        wikicode += "'''Total Matches:''' " + combined.length + '\n\n';
        wikicode += '{| class="wikitable sortable"\n';
        wikicode += '! # !! Type !! Timestamp !! User !! Edit Summary !! Diff\n';
        
        combined.forEach((rev, index) => {
            const bgColor = rev.type === 'insertion' ? '#d4edda' : '#f8d7da';
            const typeLabel = rev.type === 'insertion' ? 'Insertion' : 'Removal';
            const timestamp = new Date(rev.timestamp).toLocaleString();
            
            // Wikicode diff: {{Diff|title|prev|revid|diff}}
            const diffLink = '{{Diff|' + pageTitle + '|prev|' + rev.revid + '|diff}}';
            
            // Escape pipes and other wikicode special chars in comment
            const comment = (rev.comment || '(no comment)')
                .replace(/\|/g, '{{!}}')
                .replace(/\[\[/g, '<nowiki>[[</nowiki>')
                .replace(/\]\]/g, '<nowiki>]]</nowiki>');
            
            wikicode += '|-\n';
            wikicode += '| style="background-color: ' + bgColor + ';" | ' + (index + 1) + '\n';
            wikicode += '| style="background-color: ' + bgColor + ';" | ' + typeLabel + '\n';
            wikicode += '| style="background-color: ' + bgColor + ';" | ' + timestamp + '\n';
            wikicode += '| style="background-color: ' + bgColor + ';" | ' + rev.user + '\n';
            wikicode += '| style="background-color: ' + bgColor + ';" | ' + comment + '\n';
            wikicode += '| style="background-color: ' + bgColor + ';" | ' + diffLink + '\n';
        });
        
        wikicode += '|}\n';
        
        return wikicode;
    }
    
    // Check revision count button
    $('#check-revisions').on('click', async function() {
        const pageTitle = $('#page-title').val().trim();
        
        if (!pageTitle) {
            alert('Please enter a page title.');
            return;
        }
        
        clearResults();
        showStatus('Checking revision count...');
        
        const revCount = await getRevisionCount(pageTitle);
        
        if (revCount === null) {
            showStatus('Error: Could not retrieve revision count from XTools. The page may not exist.');
        } else {
            let statusMsg = 'Page "' + pageTitle + '" has ' + revCount.toLocaleString() + ' revisions.';
            
            if (revCount > CONFIG.MAX_REVISIONS_WARNING) {
                statusMsg += '<br><strong style="color: red;">WARNING: This page has a large number of revisions. ' +
                           'The search may take a long time and consume significant bandwidth.</strong>';
            } else {
                statusMsg += '<br><span style="color: green;">This should be safe to search.</span>';
            }
            
            showStatus(statusMsg);
        }
    });
    
    // Start search button
    $('#start-search').on('click', async function() {
        const pageTitle = $('#page-title').val().trim();
        const searchType = $('input[name="search-type"]:checked').val();
        const searchPattern = $('#search-pattern').val();
        const searchMode = $('input[name="search-mode"]:checked').val();
        const sortNewest = $('input[name="sort-order"]:checked').val() === 'newest';
        
        // Validation
        if (!pageTitle) {
            alert('Please enter a page title.');
            return;
        }
        
        if (!searchPattern) {
            alert('Please enter a search pattern.');
            return;
        }
        
        // Test regex if applicable
        if (searchType === 'regex') {
            try {
                new RegExp(searchPattern);
            } catch (e) {
                alert('Invalid regular expression: ' + e.message);
                return;
            }
        }
        
        clearResults();
        showStatus('Checking revision count...');
        
        // Check revision count first
        const revCount = await getRevisionCount(pageTitle);
        
        if (revCount === null) {
            showStatus('Error: Could not retrieve revision count. The page may not exist.');
            return;
        }
        
        showStatus('Page has ' + revCount.toLocaleString() + ' revisions.', true);
        
        if (revCount > CONFIG.MAX_REVISIONS_WARNING) {
            const proceed = confirm(
                'WARNING: This page has ' + revCount.toLocaleString() + ' revisions.\n\n' +
                'This will require approximately ' + Math.ceil(revCount / CONFIG.BATCH_SIZE) + ' API requests ' +
                'and may take several minutes.\n\n' +
                'Do you want to proceed?'
            );
            
            if (!proceed) {
                showStatus('Search cancelled by user.', true);
                return;
            }
        }
        
        // Disable button during search
        $('#start-search').prop('disabled', true).text('Searching...');
        
        try {
            showStatus('Fetching revisions...', true);
            
            const revisions = await fetchAllRevisions(pageTitle, function(requestCount, revisionCount) {
                showStatus('Request #' + requestCount + ' - Fetched ' + revisionCount.toLocaleString() + ' revisions so far...', true);
            });
            
            showStatus('Analyzing ' + revisions.length.toLocaleString() + ' revisions...', true);
            
            const results = analyzeRevisions(revisions, searchPattern, searchType === 'regex', searchMode);
            
            // Display results
            let resultsHTML = '<div style="margin-bottom: 20px;">';
            resultsHTML += '<p><strong>Search Pattern:</strong> ' + (results.isRegex ? 'Regex: ' : 'Text: ') + 
                          '<code>' + mw.html.escape(results.searchPattern) + '</code></p>';
            resultsHTML += '<p><strong>Total Revisions Analyzed:</strong> ' + revisions.length.toLocaleString() + '</p>';
            resultsHTML += '<p><strong>Insertions Found:</strong> ' + results.insertions.length + '</p>';
            resultsHTML += '<p><strong>Removals Found:</strong> ' + results.removals.length + '</p>';
            resultsHTML += '<p><strong>Total Matches:</strong> ' + (results.insertions.length + results.removals.length) + '</p>';
            resultsHTML += '</div>';
            
            // Show combined table
            resultsHTML += '<div style="margin-bottom: 30px;">';
            resultsHTML += '<h3 style="background: #e9ecef; padding: 10px; border-radius: 3px;">';
            resultsHTML += 'All Matches (';
            resultsHTML += '<span style="color: #155724;">Insertions in green</span>, ';
            resultsHTML += '<span style="color: #721c24;">Removals in red</span>';
            resultsHTML += ')';
            resultsHTML += '</h3>';
            
            // Filter results based on search mode
            const insertionsToShow = (searchMode === 'inserted' || searchMode === 'both') ? results.insertions : [];
            const removalsToShow = (searchMode === 'removed' || searchMode === 'both') ? results.removals : [];
            
            resultsHTML += formatResultsTable(insertionsToShow, removalsToShow, pageTitle, sortNewest);
            resultsHTML += '</div>';
            
            if (results.insertions.length === 0 && results.removals.length === 0) {
                resultsHTML += '<div style="padding: 15px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 3px;">';
                resultsHTML += '<p style="margin: 0;"><strong>No matches found.</strong></p>';
                resultsHTML += '</div>';
            }
            
            showResults(resultsHTML);
            showStatus('Search complete!', true);
            
            // Generate and display wikicode
            if (results.insertions.length > 0 || results.removals.length > 0) {
                const wikicode = formatWikicode(insertionsToShow, removalsToShow, pageTitle, sortNewest, results.searchPattern, results.isRegex);
                $('#wikicode-output').val(wikicode);
                $('#wikicode-area').show();
            }
            
        } catch (error) {
            showStatus('Error: ' + error, true);
            console.error('Search error:', error);
        } finally {
            $('#start-search').prop('disabled', false).text('Start Search');
        }
    });
    
})();
// </nowiki>