User:Nw520/HashCheck.js

From Meta, a Wikimedia project coordination wiki

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
// <nowiki>
mw.hook( 'wikipage.content' ).add( async function hook( /** @type {Array<HTMLElement>} */ $container ) {
	mw.hook( 'wikipage.content' ).remove( hook );

	await mw.loader.using( [ 'mediawiki.api', 'mediawiki.util' ] );

	const FILTER_COLOUR = {
		GREEN: 'invert(50%) sepia(100%) saturate(2687%) hue-rotate(120deg)',
		PURPLE: 'invert(20%) sepia(68%) saturate(2914%) hue-rotate(276deg)',
		RED: 'invert(25%) sepia(100%) saturate(6225%) hue-rotate(345deg)',
		YELLOW: 'invert(75%) sepia(50%) saturate(635%)'
	};
	const ICON = {
		CHECK: 'https://upload.wikimedia.org/wikipedia/commons/b/bd/Font_Awesome_5_solid_check.svg',
		CLOCK: 'https://upload.wikimedia.org/wikipedia/commons/2/23/Font_Awesome_5_solid_clock.svg',
		HOURGLASS: 'https://upload.wikimedia.org/wikipedia/commons/6/61/Font_Awesome_5_solid_hourglass-half.svg',
		QUESTION_MARK: 'https://upload.wikimedia.org/wikipedia/commons/9/90/Font_Awesome_5_solid_question.svg',
		TRIANGLE: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Font_Awesome_5_solid_exclamation-triangle.svg'
	};

	const strings = {
		'ext-hashcheck-dnf': {
			de: 'Fragment nicht gefunden',
			en: 'Fragment not found'
		},
		'ext-hashcheck-jump': {
			de: 'Zu erstem Vorkommen springen',
			en: 'Jump to first occurrence'
		},
		'ext-hashcheck-maxage': {
			de: 'Alter der Daten: $1 min',
			en: 'Age of data: $1 min'
		},
		'ext-hashcheck-ok': {
			de: 'Fragment gefunden',
			en: 'Fragment found'
		},
		'ext-hashcheck-pending': {
			de: 'Überprüfung läuft',
			en: 'Check in progress'
		},
		'ext-hashcheck-portlet': {
			de: 'Fragmente in Links prüfen',
			en: 'Check fragments in links'
		},
		'ext-hashcheck-queued': {
			de: 'Überprüfung eingereiht',
			en: 'Check queued'
		},
		'ext-hashcheck-warnbox-preamble': {
			de: 'In diesem Reiseführer sind Links, bei denen die verlinkten Sektionen nicht existieren:',
			en: 'In this guide are links where the linked sections do not exist:'
		}
	};

	/**
	 * @type {HTMLSpanElement}
	 */
	let maxAgeBox = null;

	/**
	 * @type {HTMLElement}
	 */
	let portlet = null;

	/**
	 * @type {HTMLDivElement}
	 */
	let warnbox = null;

	function main() {
		if ( ( mw.config.get( 'wgAction' ) !== 'submit' && mw.config.get( 'wgAction' ) !== 'view' ) || mw.config.get( 'wgNamespaceNumber' ) !== 0 ) {
			return;
		}

		mw.util.addCSS( '.ext-hashcheck-warnbox { background: #f9f9f9; border: 1px solid #f66; border-left: 10px solid #f66; box-sizing: border-box; margin: .5em 0; overflow: hidden; padding: .5em; text-align: left; width: auto; }' );

		setupStrings();
		addPortlet();
		fireHook();
	}

	function addPortlet() {
		portlet = mw.util.addPortletLink( 'p-tb', '#', mw.msg( 'ext-hashcheck-portlet' ) );
		portlet.addEventListener( 'click', ( e ) => {
			e.preventDefault();

			portlet.remove();

			scan();
		} );
	}

	/**
	 * @param {string} title
	 * @param {string} hash
	 * @param {Array<HTMLAnchorElement>} elements
	 * @param {?number} age
	 */
	function addWarning( title, hash, elements, age ) {
		if ( warnbox === null ) {
			warnbox = document.createElement( 'div' );
			warnbox.classList.add( 'ext-hashcheck-warnbox' );
			warnbox.innerHTML = `<span class="ext-hashcheck-maxage" style="float: right; font-size: .8em;"></span>
				<p>${mw.msg( 'ext-hashcheck-warnbox-preamble' )}</p>
				<ul class="ext-hashcheck-titles"></ul>`;

			maxAgeBox = warnbox.querySelector( '.ext-hashcheck-maxage' );

			document.getElementById( 'bodyContent' ).insertAdjacentElement( 'afterbegin', warnbox );
		}

		let titleList = null;
		if ( warnbox.querySelector( `li[data-title="${encodeTitle( title )}"]` ) === null ) {
			const li = document.createElement( 'li' );
			li.dataset.title = title.replace( / /g, '_' );
			li.textContent = title;

			titleList = document.createElement( 'ul' );
			li.appendChild( titleList );
			warnbox.querySelector( '.ext-hashcheck-titles' ).append( li );
		} else {
			titleList = warnbox.querySelector( `li[data-title="${encodeTitle( title )}"] > ul` );
		}

		if ( titleList.querySelector( `li[data-hash="${encodeTitle( hash )}"]` ) !== null ) {
			return;
		}

		const hashItem = document.createElement( 'li' );
		hashItem.dataset.age = age;

		const link = document.createElement( 'a' );
		link.href = elements[ 0 ].href;
		link.textContent = hash;
		link.target = '_blank';
		hashItem.appendChild( link );

		hashItem.appendChild( document.createTextNode( ' (' ) );
		const elementLink = document.createElement( 'a' );
		elementLink.href = '#';
		elementLink.textContent = `${elements.length}×`;
		elementLink.title = mw.msg( 'ext-hashcheck-jump' );
		elementLink.addEventListener( 'click', ( e ) => {
			e.preventDefault();
			elements[ 0 ].scrollIntoView( {
				behavior: 'smooth',
				block: 'center',
				inline: 'center'
			} );
		} );
		hashItem.appendChild( elementLink );
		hashItem.appendChild( document.createTextNode( ')' ) );

		titleList.appendChild( hashItem );
	}

	/**
	 * @param {string} title
	 * @return {string}
	 */
	function encodeTitle( title ) {
		return title.replace( /"/g, '\\"' ).replace( / /g, '_' );
	}

	function fireHook() {
		mw.hook( 'ext.hashCheck.loaded' ).fire( window.nw520.hashCheck );
	}

	/**
	 * @param {string} iconUrl
	 * @param {string} filter
	 * @return {string}
	 */
	function getImage( iconUrl, filter ) {
		return `<img src="${iconUrl}" style="filter: ${filter};height:1em;" />`;
	}

	/**
	 * @return {Object.<string, Object.<string, Array<HTMLAnchorElement>>>} Dictionary of titles and (dictionaries of hashes and list of elements).
	 */
	function getLinks() {
		/** @type {Object.<string, Object.<string, Array<HTMLAnchorElement>>>} */
		const links = {};

		/** @type {Array<HTMLAnchorElement>} */
		const elements = Array.from( $container[ 0 ].querySelectorAll( 'a[href^="/wiki/"][href*="#"]:not(.new), a[href^="#"]' ) );
		for ( const element of elements ) {
			if ( element.closest( '#toc' ) !== null ) {
				// Ignore table of contents
				continue;
			}
			
			const parts = decodeURI( element.attributes.href.value ).replace( /^\/wiki\//, '' )
				.replace( /_/g, ' ' )
				.match( /^(?<title>[^#]+?)?(?:#(?<hash>.*?))?$/ );
			
			if ( parts.groups.title === undefined ) {
				parts.groups.title = '';
			}
			
			if ( links[ parts.groups.title ] === undefined ) {
				links[ parts.groups.title ] = {};
			}
			
			if ( parts.groups.hash === '' || parts.groups.hash.startsWith( 'cite note-' ) || parts.groups.hash.startsWith( 'cite ref-' ) || parts.groups.hash.startsWith( '/map/' ) || parts.groups.hash.startsWith( '/maplink/' ) || parts.groups.hash.startsWith( 'movedpara' ) ) {
				continue;
			}

			if ( links[ parts.groups.title ][ parts.groups.hash ] === undefined ) {
				links[ parts.groups.title ][ parts.groups.hash ] = [ element ];
			} else {
				links[ parts.groups.title ][ parts.groups.hash ].push( element );
			}
		}

		return links;
	}

	async function scan() {
		portlet?.remove();

		const links = getLinks();
		let maxAge = 0;

		// Set to queued
		for ( const hashes of Object.values( links ) ) {
			for ( const elements of Object.values( hashes ) ) {
				for ( const element of elements ) {
					const controlItem = document.createElement( 'span' );
					controlItem.classList.add( 'ext-hashcheck-control' );
					controlItem.innerHTML = getImage( ICON.HOURGLASS, FILTER_COLOUR.PURPLE );
					controlItem.title = `[HashCheck] ${mw.msg( 'ext-hashcheck-queued' )}`;

					element.appendChild( controlItem );
					element.classList.add( 'ext-hashcheck-queued' );
				}
			}
		}

		for ( let [ title, hashes ] of Object.entries( links ) ) {
			if ( title === '' && mw.config.get( 'wgAction' ) === 'view' ) {
				// Current page and in view-mode, no request needed
				for ( const [ hash, elements ] of Object.entries( hashes ) ) {
					const match = document.querySelector( `*[id="${encodeTitle( hash )}"]` );
					for ( const element of elements ) {
						/** @type {HTMLSpanElement} */
						const controlItem = element.querySelector( '.ext-hashcheck-control' );
						if ( match !== null ) {
							controlItem.innerHTML = getImage( ICON.CHECK, FILTER_COLOUR.GREEN );
							controlItem.title = `[HashCheck] ${mw.msg( 'ext-hashcheck-ok' )}`;
							element.classList.add( 'ext-hashcheck-ok' );
						} else {
							controlItem.innerHTML = getImage( ICON.TRIANGLE, FILTER_COLOUR.RED );
							controlItem.title = `[HashCheck] ${mw.msg( 'ext-hashcheck-dnf' )}`;
							element.classList.add( 'ext-hashcheck-dnf' );
						}
					}
					
					if ( match === null ) {
						addWarning( title === '' ? mw.config.get( 'wgTitle' ) : title, hash, elements, 0 );
					}
				}
			} else {
				if ( title === '' ) {
					title = mw.config.get( 'wgPageName' );
				}
				
				// Set to pending
				for ( const elements of Object.values( hashes ) ) {
					for ( const element of elements ) {
						/** @type {HTMLSpanElement} */
						const controlItem = element.querySelector( '.ext-hashcheck-control' );
						controlItem.innerHTML = getImage( ICON.CLOCK, FILTER_COLOUR.PURPLE );
						controlItem.title = `[HashCheck] ${mw.msg( 'ext-hashcheck-pending' )}`;
	
						element.classList.add( 'ext-hashcheck-queued' );
						element.classList.add( 'ext-hashcheck-pending' );
					}
				}
	
				// Make request
				const request = jQuery.ajax( {
					data: {
						redirect: true
					},
					dataType: 'html',
					headers: {
						'Api-User-Agent': 'HashCheck; [[m:User:nw520/HashCheck.js]]'
					},
					url: `${mw.config.get( 'wgServer' )}/api/rest_v1/page/html/${encodeURIComponent( title.replace( / /g, '_' ) )}?redirect=true`
				} );
				const raw = await request;
				const age = request.getResponseHeader( 'age' ) !== null ? parseInt( request.getResponseHeader( 'age' ) ) : null;
				const parser = new DOMParser();
				const article = parser.parseFromString( raw, 'text/html' );
	
				maxAge = Math.max( maxAge, age ?? 0 );
	
				for ( const [ hash, elements ] of Object.entries( hashes ) ) {
					const match = article.querySelector( `*[id="${encodeTitle( hash )}"]` );
	
					for ( const element of elements ) {
						element.classList.remove( 'ext-hashcheck-pending' );
	
						/** @type {HTMLSpanElement} */
						const controlItem = element.querySelector( '.ext-hashcheck-control' );
						if ( match !== null ) {
							controlItem.innerHTML = getImage( ICON.CHECK, FILTER_COLOUR.GREEN );
							controlItem.title = `[HashCheck] ${mw.msg( 'ext-hashcheck-ok' )}`;
							element.classList.add( 'ext-hashcheck-ok' );
						} else {
							controlItem.innerHTML = getImage( ICON.TRIANGLE, FILTER_COLOUR.RED );
							controlItem.title = `[HashCheck] ${mw.msg( 'ext-hashcheck-dnf' )}`;
							element.classList.add( 'ext-hashcheck-dnf' );
						}
					}
					if ( match === null ) {
						addWarning( title, hash, elements, age );
	
						if ( maxAge % 60 >= 3 ) {
							maxAgeBox.textContent = mw.msg( 'ext-hashcheck-maxage', maxAge % 60 );
						}
					}
				}	
			}
		}
	}

	function setupStrings() {
		const lang = mw.config.get( 'wgUserLanguage' );
		mw.messages.set( Object.fromEntries( Object.keys( strings ).map( ( stringKey ) => {
			return [ stringKey, strings[ stringKey ][ lang ] ?? strings[ stringKey ].en ];
		} ) ) );
	}

	if ( window.nw520 === undefined ) {
		window.nw520 = [];
	}
	window.nw520.hashCheck = {
		scan: scan
	};

	main();
} );
// </nowiki>