User:Polygnotus/Scripts/RevisionSearch.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump.
This code will be executed when previewing this page.
This code will be executed when previewing this page.
Documentation for this user script can be added at User:Polygnotus/Scripts/RevisionSearch.
// <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>