MediaWiki:Gadget-ArticleReview.js

Revision as of 02:45, 3 November 2026 by Administrat0r (talk | contribs) (Add dismissible article review interface)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
( function () {
	'use strict';

	var rootId = 'shalombux-article-review';

	function element( name, className, text ) {
		var node = document.createElement( name );
		if ( className ) {
			node.className = className;
		}
		if ( text !== undefined ) {
			node.textContent = String( text );
		}
		return node;
	}

	function safeLink( label, address, newWindow ) {
		var url;
		try {
			url = new URL( address, window.location.href );
		} catch ( error ) {
			return null;
		}
		if ( url.protocol !== 'https:' && url.protocol !== 'http:' ) {
			return null;
		}
		var link = element( 'a', '', label );
		link.href = url.href;
		if ( newWindow ) {
			link.target = '_blank';
			link.rel = 'noopener noreferrer';
		}
		return link;
	}

	function initialize() {
		var data = window.shalombuxArticleReviewData;
		var query = new URLSearchParams( window.location.search );
		var pageId = Number( mw.config.get( 'wgArticleId' ) );
		var viewedRevision = Number( mw.config.get( 'wgRevisionId' ) );
		if ( document.getElementById( rootId ) || !data || !data.pages ||
			mw.config.get( 'wgNamespaceNumber' ) !== 0 || pageId <= 0 || viewedRevision <= 0 ||
			mw.config.get( 'wgAction' ) !== 'view' || query.has( 'diff' ) || query.has( 'veaction' ) ||
			mw.config.get( 'wgDiffOldId' ) || mw.config.get( 'wgDiffNewId' ) ) {
			return;
		}
		var review = data.pages[ String( pageId ) ];
		if ( !review || !Number( review.reviewedRevision ) ) {
			return;
		}

		var reviewedRevision = Number( review.reviewedRevision );
		var currentRevision = Number( mw.config.get( 'wgCurRevisionId' ) ) || viewedRevision;
		var storageKey = 'shalombux-article-review:' + String( data.version ) + ':' +
			pageId + ':' + reviewedRevision;
		var dismissed = false;
		try {
			dismissed = window.localStorage.getItem( storageKey ) === 'dismissed';
		} catch ( error ) {
			// The card remains usable when browser storage is unavailable.
		}

		var root = element( 'div', 'sb-article-review' );
		root.id = rootId;
		var badge = element( 'button', 'sb-article-review__badge', 'Article review' );
		badge.type = 'button';
		badge.setAttribute( 'aria-controls', rootId + '-panel' );
		badge.setAttribute( 'aria-expanded', 'false' );
		badge.title = 'Show the article review';

		var panel = element( 'section', 'sb-article-review__panel' );
		panel.id = rootId + '-panel';
		panel.setAttribute( 'aria-labelledby', rootId + '-heading' );
		var header = element( 'div', 'sb-article-review__header' );
		var heading = element( 'h2', 'sb-article-review__heading', 'Article review' );
		heading.id = rootId + '-heading';
		var close = element( 'button', 'sb-article-review__close', '\u00d7' );
		close.type = 'button';
		close.setAttribute( 'aria-label', 'Close article review' );
		close.title = 'Close article review (Escape)';
		header.appendChild( heading );
		header.appendChild( close );
		panel.appendChild( header );

		var body = element( 'div', 'sb-article-review__body' );
		body.appendChild( element( 'p', 'sb-article-review__date', 'Reviewed ' + String( data.reviewDate ) ) );
		if ( viewedRevision !== reviewedRevision || currentRevision !== reviewedRevision ) {
			var stale = viewedRevision !== reviewedRevision ?
				'This review covers a different revision. The text you are viewing has not been rechecked; the findings below may no longer apply.' :
				'You are viewing the reviewed revision. A newer revision exists and has not been rechecked.';
			body.appendChild( element( 'p', 'sb-article-review__stale', stale ) );
		}
		var revisionLink = safeLink( 'View reviewed revision ' + reviewedRevision,
			mw.util.getUrl( review.title || mw.config.get( 'wgPageName' ), { oldid: reviewedRevision } ), false );
		if ( revisionLink ) {
			var revisionParagraph = element( 'p', 'sb-article-review__revision' );
			revisionParagraph.appendChild( revisionLink );
			body.appendChild( revisionParagraph );
		}

		var issues = Array.isArray( review.issues ) ? review.issues : [];
		if ( issues.length ) {
			var list = element( 'ul', 'sb-article-review__issues' );
			issues.forEach( function ( issue ) {
				if ( !issue || typeof issue !== 'object' ) {
					return;
				}
				var kind = [ 'error', 'review', 'note' ].indexOf( issue.kind ) !== -1 ? issue.kind : 'note';
				var labels = { error: 'Verified error', review: 'Review needed', note: 'Note' };
				var item = element( 'li', 'sb-article-review__issue sb-article-review__issue--' + kind );
				item.appendChild( element( 'span', 'sb-article-review__kind', labels[ kind ] ) );
				item.appendChild( element( 'strong', 'sb-article-review__issue-title', issue.title || 'Review note' ) );
				item.appendChild( element( 'p', 'sb-article-review__detail', issue.detail || '' ) );
				if ( Array.isArray( issue.links ) ) {
					var links = element( 'div', 'sb-article-review__links' );
					issue.links.forEach( function ( source ) {
						if ( !source || typeof source.url !== 'string' ) {
							return;
						}
						var link = safeLink( source.label || 'Source', source.url, true );
						if ( link ) {
							links.appendChild( link );
						}
					} );
					if ( links.childNodes.length ) {
						item.appendChild( links );
					}
				}
				list.appendChild( item );
			} );
			body.appendChild( list );
		} else {
			body.appendChild( element( 'p', 'sb-article-review__no-issues',
				'No article-specific issue recorded in this limited review.' ) );
		}

		body.appendChild( element( 'p', 'sb-article-review__ai',
			'AI indicators: none found in reviewed text/links; authorship unverified.' ) );
		var scope = element( 'details', 'sb-article-review__scope' );
		scope.appendChild( element( 'summary', '', 'Review scope' ) );
		scope.appendChild( element( 'p', 'sb-article-review__unicode', review.unicodeNote ||
			'Unicode scan: no flagged hidden characters found in the reviewed source.' ) );
		scope.appendChild( element( 'p', 'sb-article-review__coverage',
			review.sourceReview === 'spot-checked' ?
				'Selected citations were spot-checked; other sources remain unverified.' :
				'Citation destinations were not checked for this article.' ) );
		scope.appendChild( element( 'p', 'sb-article-review__coverage',
			'Wikipedia policy links are review guidance; no complete policy assessment.' ) );
		body.appendChild( scope );
		panel.appendChild( body );
		root.appendChild( badge );
		root.appendChild( panel );

		var isOpen = false;
		var hoverBlocked = false;
		var hoverMayOpenAfter = 0;
		var suppressFocus = false;
		var suspended = false;

		function rememberDismissal( value ) {
			try {
				if ( value ) {
					window.localStorage.setItem( storageKey, 'dismissed' );
				} else {
					window.localStorage.removeItem( storageKey );
				}
			} catch ( error ) {
				// Dismissal still works for the current page without storage.
			}
		}

		function openPanel( remember ) {
			if ( suspended ) {
				return;
			}
			isOpen = true;
			panel.hidden = false;
			badge.setAttribute( 'aria-expanded', 'true' );
			if ( remember ) {
				rememberDismissal( false );
			}
		}

		function closePanel( returnFocus ) {
			isOpen = false;
			panel.hidden = true;
			badge.setAttribute( 'aria-expanded', 'false' );
			hoverBlocked = true;
			hoverMayOpenAfter = Date.now() + 800;
			rememberDismissal( true );
			if ( returnFocus ) {
				suppressFocus = true;
				badge.focus( { preventScroll: true } );
				suppressFocus = false;
			}
		}

		close.addEventListener( 'click', function () {
			closePanel( true );
		} );
		badge.addEventListener( 'click', function () {
			openPanel( true );
		} );
		badge.addEventListener( 'focus', function () {
			if ( !suppressFocus ) {
				openPanel( false );
			}
		} );
		badge.addEventListener( 'mouseenter', function () {
			if ( !hoverBlocked && Date.now() >= hoverMayOpenAfter &&
				window.matchMedia( '(hover: hover)' ).matches ) {
				openPanel( false );
			}
		} );
		root.addEventListener( 'mouseleave', function () {
			hoverBlocked = false;
		} );
		document.addEventListener( 'keydown', function ( event ) {
			if ( event.key === 'Escape' && isOpen && !suspended ) {
				var focusInside = root.contains( document.activeElement );
				closePanel( focusInside );
				if ( focusInside ) {
					event.preventDefault();
				}
			}
		} );

		function updateEditorVisibility() {
			var editorClasses = [ 've-active', 've-activated', 've-activating', 've-deactivating' ];
			suspended = editorClasses.some( function ( name ) {
				return document.documentElement.classList.contains( name ) || document.body.classList.contains( name );
			} );
			root.hidden = suspended;
		}
		mw.hook( 've.activationStart' ).add( function () {
			suspended = true;
			root.hidden = true;
		} );
		mw.hook( 've.deactivationComplete' ).add( updateEditorVisibility );
		if ( window.MutationObserver ) {
			var observer = new MutationObserver( updateEditorVisibility );
			observer.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
			observer.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
		}

		// Keep review UI outside article content and VisualEditor serialization.
		document.body.appendChild( root );
		panel.hidden = dismissed;
		if ( !dismissed ) {
			openPanel( false );
		}
		updateEditorVisibility();
	}

	mw.loader.using( 'mediawiki.util' ).then( function () {
		if ( document.readyState === 'loading' ) {
			document.addEventListener( 'DOMContentLoaded', initialize, { once: true } );
		} else {
			initialize();
		}
	} );
}() );