User:Rusalkii/previewRedirectContext.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.
This user script seems to have a documentation page at User:Rusalkii/previewRedirectContext.
// @ts-check
// Shows the paragraph containing the redirect term in the target article.
// Also searches Wikipedia for other articles containing the redirect term.
( function () {
'use strict';
// Only run on redirect pages
if ( !mw.config.get( 'wgIsRedirect' ) ) {
return;
}
// Minimum combined length (in characters) of the fallback preview shown
// when the redirect term isn't found in the target article.
const PREVIEW_MIN_LENGTH = 300;
// A cleaned paragraph shorter than this (after stripping templates,
// tables, and file links) is treated as boilerplate, not real prose,
// and skipped when building the preview.
const MIN_SUBSTANTIAL_LENGTH = 20;
mw.loader.using( [ 'mediawiki.api', 'mediawiki.util' ] ).then( function () {
addStyles();
// user-defined pref, defaults to true
let autoRun = true;
if ( typeof autoRunRedirectContext !== 'undefined' && autoRunRedirectContext === false ) {
autoRun = false;
}
// user-defined pref, defaults to false: when the redirect term isn't
// found in the target article, show a preview of the article's
// opening paragraph(s) instead of just the "not found" message.
let showPreviewOnNoMatch = false;
if ( typeof redirectContextShowPreview !== 'undefined' && redirectContextShowPreview === true ) {
showPreviewOnNoMatch = true;
}
// Looked up once and reused everywhere it's needed.
const redirectMsg = document.querySelector( '.redirectMsg' );
if ( autoRun ) {
runContextFinder();
} else {
addButton();
}
// css classes instead of inline colors
// night mode support
function addStyles() {
mw.util.addCSS( `
.rcf-button {
margin-left: 10px;
padding: 6px 12px;
background-color: var( --background-color-progressive, #3366cc );
color: var( --color-inverted, #fff );
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
vertical-align: middle;
}
.rcf-button-hover {
background-color: var( --background-color-progressive--hover, #2952a3 );
}
.rcf-container {
margin: 20px 0;
padding: 15px;
border: 2px solid var( --border-color-progressive, #3366cc );
background-color: var( --background-color-progressive-subtle, #f0f8ff );
color: var( --color-base, #202122 );
border-radius: 5px;
}
.rcf-result-box {
background-color: var( --background-color-base, #fff );
color: var( --color-base, #202122 );
padding: 10px;
margin-top: 10px;
}
.rcf-error {
color: var( --color-error, #cc0000 );
}
.rcf-not-found {
margin-top: 10px;
color: var( --color-subtle, #666 );
}
.rcf-search-container {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var( --border-color-progressive, #3366cc );
}
.rcf-mark {
background-color: var( --background-color-content-removed, #ffff00 );
color: var( --color-base, #202122 );
font-weight: bold;
}
.rcf-mark-search {
background-color: var( --background-color-content-removed, #ffffcc );
color: var( --color-base, #202122 );
font-weight: normal;
}
` );
}
function addButton() {
if ( !redirectMsg ) {
return;
}
const button = document.createElement( 'button' );
button.textContent = 'Find in target';
button.className = 'rcf-button';
button.onmouseover = function () { this.classList.add( 'rcf-button-hover' ); };
button.onmouseout = function () { this.classList.remove( 'rcf-button-hover' ); };
button.addEventListener( 'click', function () {
button.disabled = true;
runContextFinder();
} );
redirectMsg.appendChild( button );
}
function runContextFinder() {
const api = new mw.Api( {
ajax: {
headers: {
'Api-User-Agent': 'RedirectContextFinder/1.0'
}
}
} );
const redirectTitle = mw.config.get( 'wgTitle' );
api.get( {
action: 'query',
prop: 'revisions',
titles: mw.config.get( 'wgPageName' ),
redirects: 1,
rvprop: 'content',
rvslots: 'main',
formatversion: 2
} ).then( function ( data ) {
const redirects = data.query.redirects;
if ( !redirects || redirects.length === 0 ) {
return;
}
const targetTitle = redirects[ 0 ].to;
const page = data.query.pages[ 0 ];
const container = document.createElement( 'div' );
container.className = 'redirect-context-finder rcf-container';
if ( redirectMsg ) {
redirectMsg.parentNode.insertBefore( container, redirectMsg.nextSibling );
}
if ( !page.revisions ) {
container.innerHTML = '<strong class="rfc-error">Error: Could not load target page</strong>';
return;
}
const wikitext = page.revisions[ 0 ].slots.main.content;
const result = findRedirectContext( redirectTitle, wikitext );
if ( result ) {
container.innerHTML = '<div class="rfc-result-box">' +
result.paragraph +
'</div>';
} else {
let previewHtml = '';
if ( showPreviewOnNoMatch ) {
const previewParagraphs = getPreviewParagraphs( wikitext, PREVIEW_MIN_LENGTH );
if ( previewParagraphs.length > 0 ) {
previewHtml = '<div class="rfc-result-box">' +
previewParagraphs.map( function ( p ) {
return '<p style="margin: 0 0 10px 0;">' + p + '</p>';
} ).join( '' ) +
'</div>';
}
}
container.innerHTML = '<div class="rfc-not-found">' +
'<strong class="rfc-error">✗</strong> "<strong>' + mw.html.escape( redirectTitle ) + '</strong>" does not appear in the target. ' +
'</div>' + previewHtml;
}
// Add search results section
const searchContainer = document.createElement( 'div' );
searchContainer.className = 'rcf-search-container';
container.appendChild( searchContainer );
searchWikipedia( api, redirectTitle, targetTitle, searchContainer );
} ).catch( function () {
if ( !redirectMsg ) {
return;
}
const container = document.createElement( 'div' );
container.innerHTML = '<strong class="rcf-error">Error loading target page</strong>';
redirectMsg.parentNode.insertBefore( container, redirectMsg.nextSibling );
} );
}
function searchWikipedia( api, searchTerm, targetTitle, container ) {
api.get( {
action: 'query',
list: 'search',
srsearch: '"' + searchTerm + '"',
srnamespace: 0,
srlimit: 5,
srwhat: 'text',
srprop: 'snippet|titlesnippet'
} ).then( function ( data ) {
const results = data.query.search;
// Filter out the target article itself
const otherArticles = results.filter( function ( result ) {
return result.title !== targetTitle;
} );
const totalResults = data.query.searchinfo.totalhits;
const otherCount = Math.max( 0, totalResults - 1 ); // Exclude target article
const parts = [ '<div class="mw-search-results-container">', '<strong>Search Results:</strong> ' ];
if ( otherCount === 0 ) {
parts.push( '"<strong>' + mw.html.escape( searchTerm ) + '</strong>" was not found in any other articles.' );
parts.push( '</div>' );
} else {
parts.push( 'Found in <strong>' + otherCount + '</strong> other article' + ( otherCount !== 1 ? 's' : '' ) );
parts.push( '</div>' );
parts.push( '<ul class="mw-search-results">' );
for ( let i = 0; i < otherArticles.length; i++ ) {
const article = otherArticles[ i ];
const articleUrl = mw.util.getUrl( article.title );
parts.push( '<li class="mw-search-result">' );
parts.push( '<a href="' + articleUrl + '" title="' + mw.html.escape( article.title ) + '">' );
parts.push( mw.html.escape( article.title ) );
parts.push( '</a>' );
// Highlight the search term in snippets
if ( article.snippet ) {
const highlightedSnippet = article.snippet.replace(
/<span class="searchmatch">(.*?)<\/span>/gi,
'<mark class="rcf-mark-search">$1</mark>'
);
parts.push( '<div class="searchresult">' + highlightedSnippet + '</div>' );
}
parts.push( '</li>' );
}
parts.push( '</ul>' );
if ( otherCount > 5 ) {
const searchUrl = mw.util.getUrl( 'Special:Search', { search: '"' + searchTerm + '"' } ) + '&fulltext=1&ns0=1';
parts.push( '<div style="margin-top: 10px;"><a href="' + searchUrl + '">View all ' +
otherCount + ' results →</a></div>' );
}
}
container.innerHTML = parts.join( '' );
} ).catch( function () {
container.innerHTML = '<div class="rcf-error" style="margin-top: 10px;">Error performing search</div>';
} );
}
function createRegex( term ) {
// Create a version without parenthetical content
const termWithoutParens = term.replace( /\s*\([^)]*\)\s*/g, '' ).trim();
function createPattern( str ) {
// Escape special regex characters except spaces and hyphens
let pattern = str.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
// Replace spaces with flexible whitespace/punctuation pattern
pattern = pattern.replace( /\s+/g, '[\\s\\-–—]*' );
// Replace hyphens/dashes with flexible dash pattern (matches -, –, —, or absence)
pattern = pattern.replace( /[\-–—]/g, '[\\s\\-–—]*' );
// Replace punctuation with optional pattern
pattern = pattern.replace( /\./g, '\\.?' );
pattern = pattern.replace( /!/g, '!?' );
pattern = pattern.replace( /,/g, ',?' );
pattern = pattern.replace( /'/g, '[\']?' );
pattern = pattern.replace( /"/g, '[""]?' );
return pattern;
}
const patterns = [ createPattern( term ) ];
// Only add the version without parens if it's different
if ( termWithoutParens !== term && termWithoutParens.length > 0 ) {
patterns.push( createPattern( termWithoutParens ) );
}
return new RegExp( '(' + patterns.join( '|' ) + ')', 'gi' );
}
// Removes every balanced `open ... close` span from text
function stripBalanced( text, open, close ) {
// Collects the runs of text that survive (depth === 0) as slices
// and joins them at the end, rather than concatenating one
// character at a time. For markup-heavy text (an infobox with
// many small removed fields) this does far fewer string
// operations than the equivalent char-by-char build-up.
const parts = [];
let spanStart = 0;
let depth = 0;
let i = 0;
const len = text.length;
while ( i < len ) {
if ( text.startsWith( open, i ) ) {
if ( depth === 0 ) {
parts.push( text.slice( spanStart, i ) );
}
depth++;
i += open.length;
continue;
}
if ( depth > 0 && text.startsWith( close, i ) ) {
depth--;
i += close.length;
if ( depth === 0 ) {
spanStart = i;
}
continue;
}
i++;
}
// If we ended inside an unterminated span (depth > 0), the
// trailing text is dropped, matching the original behavior.
if ( depth === 0 ) {
parts.push( text.slice( spanStart ) );
}
return parts.join( '' );
}
// Removes [[File:...]] / [[Image:...]] embeds
function stripFileLinks( text ) {
// Same slice-batching approach as stripBalanced, for the same reason.
const parts = [];
let spanStart = 0;
let i = 0;
const len = text.length;
while ( i < len ) {
if ( text[ i ] === '[' && text[ i + 1 ] === '[' && /^(File|Image):/i.test( text.slice( i + 2, i + 9 ) ) ) {
parts.push( text.slice( spanStart, i ) );
let depth = 1;
let j = i + 2;
while ( j < len && depth > 0 ) {
if ( text[ j ] === '[' && text[ j + 1 ] === '[' ) {
depth++;
j += 2;
} else if ( text[ j ] === ']' && text[ j + 1 ] === ']' ) {
depth--;
j += 2;
} else {
j++;
}
}
i = j;
spanStart = j;
continue;
}
i++;
}
parts.push( text.slice( spanStart ) );
return parts.join( '' );
}
function cleanParagraph( para ) {
return para
// Self-closing refs (<ref name="x" />) MUST be stripped first.
.replace( /<ref[^>]*\/>/gi, '' ) // Remove self-closing refs
.replace( /<ref[^>]*>[\s\S]*?<\/ref>/gi, '' ) // Remove refs
.trim();
}
// Strips refs, templates, tables, and file/image embeds from raw wikitext
function cleanWikitext( wikitext ) {
// Self-closing refs must be stripped before the open/close pair pattern
let cleaned = wikitext
.replace( /<ref[^>]*\/>/gi, '' ) // Remove self-closing refs
.replace( /<ref[^>]*>[\s\S]*?<\/ref>/gi, '' ); // Remove refs
cleaned = stripBalanced( cleaned, '{{', '}}' ); // templates
cleaned = stripBalanced( cleaned, '{|', '|}' ); // tables
cleaned = stripFileLinks( cleaned );
return cleaned;
}
function findRedirectContext( searchTerm, wikitext ) {
const regex = createRegex( searchTerm );
const paragraphs = wikitext.split( /\n\n+/ );
for ( let i = 0; i < paragraphs.length; i++ ) {
const para = paragraphs[ i ];
// Check if paragraph contains the search term
if ( para.match( regex ) ) {
let cleaned = cleanParagraph( para );
// Highlight the search term
cleaned = cleaned.replace( regex, '<mark class="rcf-mark">$1</mark>' );
return {
paragraph: cleaned
};
}
}
return null;
}
// Builds a preview from the start of the article: the first paragraph,
// plus additional paragraphs if the first one is too short to be a
// useful preview on its own. Paragraphs that are too short even after
// cleaning (e.g. a leftover hatnote fragment) are skipped.
function getPreviewParagraphs( wikitext, minLength ) {
const paragraphs = cleanWikitext( wikitext ).split( /\n\n+/ );
const collected = [];
let totalLength = 0;
for ( let i = 0; i < paragraphs.length; i++ ) {
// Collapsing runs of whitespace into single spaces can only
// shorten text, never lengthen it, so if the plain trimmed
// paragraph is already below the threshold, the collapsed
// version will be too — skip the two regex passes below for
// these (typically the empty remnants left where a whole
// infobox/template used to be).
const trimmed = paragraphs[ i ].trim();
if ( trimmed.length < MIN_SUBSTANTIAL_LENGTH ) {
continue;
}
// Collapse whitespace left behind by the markup removal above.
const cleaned = trimmed
.replace( /[ \t]+/g, ' ' )
.replace( /\s*\n\s*/g, ' ' )
.trim();
if ( !cleaned || cleaned.length < MIN_SUBSTANTIAL_LENGTH ) {
continue;
}
collected.push( cleaned );
totalLength += cleaned.length;
if ( totalLength >= minLength ) {
break;
}
}
return collected;
}
} );
}() );