Jump to content

MediaWiki:Gadget-Synchronizer.js

From mediawiki.org

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.
/**
 * Synchronizer is a tool for synchronizing Lua modules across Wikimedia wikis
 *
 * Documentation: https://www.mediawiki.org/wiki/Synchronizer
 * Author: User:Sophivorus
 * License: CC-BY-SA-4.0
 */
window.Synchronizer = {

	init: function () {

		// Make sure the user is logged in
		if ( !mw.config.get( 'wgUserName' ) ) {
			var page = mw.config.get( 'wgPageName' );
			var href = mw.util.getUrl( 'Special:Login', { returnto: page } );
			return Synchronizer.error( 'You need to <a href="' + href + '">log in</a> to use Synchronizer' );
		}

		Synchronizer.makeForm();
	},

	makeForm: function () {

		// Get cookies
		var entity = mw.cookie.get( 'SynchronizerEntity' );
		var master = mw.cookie.get( 'SynchronizerMaster' );

		// Define elements
		var entityInput = new OO.ui.TextInputWidget( { id: 'synchronizer-input-entity', required: true, value: entity, placeholder: 'Q52428273' } );
		var masterInput = new OO.ui.TextInputWidget( { id: 'synchronizer-input-master', required: true, value: master, placeholder: 'enwiki' } );
	 	var buttonInput = new OO.ui.ButtonInputWidget( { label: 'Load', flags: [ 'primary', 'progressive' ] } );
		var entityLayout = new OO.ui.FieldLayout( entityInput, { label: 'Module entity', align: 'top', help: 'Wikidata entity of the module to synchronize, for example "Q52428273" for Module:Excerpt' } );
		var masterLayout = new OO.ui.FieldLayout( masterInput, { label: 'Master wiki', align: 'top', help: 'Wiki ID of the master version of the module, for example "enwiki" for Module:Excerpt' } );
		var buttonLayout = new OO.ui.FieldLayout( buttonInput );
		var layout = new OO.ui.HorizontalLayout( { id: 'synchronizer-form', items: [ entityLayout, masterLayout, buttonLayout ] } );

		// CSS tweaks
		entityInput.$element.css( 'max-width', 150 );
		masterInput.$element.css( 'max-width', 150 );
		buttonInput.$element.css( { 'position': 'relative', 'bottom': '1px' } );

		// Bind events
		buttonInput.on( 'click', Synchronizer.initTable );

		// Add to DOM
		$( '#synchronizer' ).html( layout.$element );
	},

	initTable: function () {
		var entity = $( '#synchronizer-input-entity input' ).val();
		var master = $( '#synchronizer-input-master input' ).val();
		if ( !entity || !master ) {
			return;
		}

		// Set cookies
		mw.cookie.set( 'SynchronizerEntity', entity );
		mw.cookie.set( 'SynchronizerMaster', master );

		// Make wrapper div
		var $div = $( '<div>Loading...</div>' ).attr( 'id', 'synchronizer-table' ).css( 'margin', '.5em 0' );
		$( '#synchronizer-table' ).remove(); // Remove any previous table
		$( '#synchronizer' ).append( $div );

		// Actually make the table
		var table = new Synchronizer.Table( entity, master );
		table.init();
	},

	Table: function ( entity, master ) {

		/**
		 * Wikidata entity ID
		 * @type {String} ID of the Wikidata entity
		 */
		this.entity = entity;

		/**
		 * Data of the master module
		 * @type {Object} Map from data key to data value
		 */
		this.master = master;

		/**
		 * Data of the other modules
		 * @type {Array} Array of data objects, each similar to the master data object defined above
		 */
		this.modules = [];

		this.init = function () {
			var table = this;

			// Track usage
			mw.track( 'counter.gadget_Synchronizer.init' );

			// Set the basic data of the master module
			table.master = {
				wiki: master,
				status: 'Master'
			};

			// Get more data from Wikidata
			table.getWikidataData();
		};

		this.getWikidataData = function () {
			var table = this;
			new mw.ForeignApi( '//www.wikidata.org/w/api.php' ).get( {
				action: 'wbgetentities',
				props: 'info|sitelinks/urls',
				normalize: 1,
				ids: table.entity
			} ).done( function ( data ) {
				//console.log( data );
				var entity = Object.values( data.entities )[0];
				var sitelinks = entity.sitelinks;
				var sitelink = sitelinks[ table.master.wiki ]; // Save the master sitelink
				delete sitelinks[ table.master.wiki ]; // Then remove it
				if ( $.isEmptyObject( sitelinks ) ) {
					return table.error( 'No pages associated to ' + table.entity );
				}

				// Save master data
				table.master.title = sitelink.title;
				table.master.url = sitelink.url;
				table.master.api = sitelink.url.replace( /\/wiki\/.+/, '/w/api.php' );

				// Save modules data
				for ( var key in sitelinks ) {
					sitelink = sitelinks[ key ];
					var module = {
						wiki: sitelink.site,
						title: sitelink.title,
						url: sitelink.url,
						api: sitelink.url.replace( /\/wiki\/.+/, '/w/api.php' )
					};
					table.modules.push( module );
				}

				table.makeTable();
				table.getMasterData();

			} ).fail( function ( error, data ) {
				//console.log( error, data );
				table.error( data.error.info );
			} );
		};

		this.getMasterData = function () {
			var table = this;
			var api = new mw.ForeignApi( table.master.api );
			api.get( {
				formatversion: 2,
				action: 'query',
				prop: 'revisions',
				rvprop: 'sha1|content',
				rvslots: 'main',
				titles: table.master.title
			} ).done( function ( data ) {
				var revision = data.query.pages[0].revisions[0];
				table.master.sha1 = revision.sha1;
				table.master.content = revision.slots.main.content;

				// We need to do a separate API call to get the sha1 of 500 revisions
				// because "content" is considered an "expensive" property
				// so if we request all together we only get 50 revisions
				api.get( {
					formatversion: 2,
					action: 'query',
					prop: 'revisions',
					rvprop: 'sha1',
					rvlimit: 'max',
					titles: table.master.title
				} ).done( function ( data ) {
					var revisions = data.query.pages[0].revisions;
					table.master.hashes = revisions.map( function ( revision ) { return revision.sha1; } );

					// Get and update the status of all the modules sequentially,
					// to honor https://www.mediawiki.org/wiki/API:Etiquette
					var sequence = Promise.resolve();
					for ( var module of table.modules ) {
						sequence = sequence.finally( table.updateStatus.bind( table, module ) );
					}

					// Finally, after the last one, update the master row
					sequence = sequence.finally( table.updateRow.bind( table, table.master ) );
				} );
			} );
		},

		this.updateStatus = function ( module ) {
			var table = this;
			var api = new mw.ForeignApi( module.api );
			return api.get( {
				formatversion: 2,
				action: 'query',
				prop: 'revisions|info',
				rvprop: 'sha1',
				inprop: 'protection',
				meta: 'siteinfo',
				siprop: 'namespaces',
				titles: module.title
			} ).done( function ( data ) {
				//console.log( data );

				// Figure out the level of protection
				var page = data.query.pages[0];
				var namespace = data.query.namespaces[ page.ns ];
				if ( 'namespaceprotection' in namespace ) {
					module.protection = namespace.namespaceprotection;
				}
				for ( var protection of page.protection ) {
					if ( protection.type === 'edit' ) {
						module.protection = protection.level;
					}
				}

				// Figure out the status
				var revision = page.revisions[0];
				module.lastrevid = page.lastrevid;
				module.sha1 = revision.sha1;
				if ( module.sha1 === table.master.sha1 ) {
					module.status = 'Updated';
					table.updateRow( module );
				} else {
					var revisionsBehind = table.master.hashes.indexOf( module.sha1 );
					if ( revisionsBehind > -1 ) {
						module.status = 'Outdated';
						module.revisionsBehind = revisionsBehind;
						table.updateRow( module );
					} else {

						// If we reach this point, it means the module either forked
						// or is unrelated (no common history with master)
						// so we need an extra request to figure out which
						api.get( {
							formatversion: 2,
							action: 'query',
							prop: 'revisions',
							rvprop: 'sha1|ids',
							rvlimit: 'max',
							titles: module.title
						} ).done( function ( data ) {
							var revisions = data.query.pages[0].revisions;
							var revisionsAhead = 0; // Number of revisions to the module since it forked
							var revisionsToMaster; // Number of revisions to master since the module forked
							for ( var revision of revisions ) {
								revisionsAhead++;
								revisionsToMaster = table.master.hashes.indexOf( revision.sha1 );
								if ( revisionsToMaster > -1 ) {
									break;
								}
							}
							if ( revisionsAhead === revisions.length ) {
								module.status = 'Unrelated';
							} else {
								module.status = 'Forked';
								module.revisionsAhead = revisionsAhead;
								module.revisionsToMaster = revisionsToMaster;
								module.forkedRevision = revision.revid; // ID of the revision that forked
							}
							table.updateRow( module );
						} );
					}
				}
			} );
		};

		this.updateRow = function ( module ) {
			var table = this;
			var status, color, title, $button, $button2;

			if ( module.status === 'Master' ) {
				status = 'Master';
				color = '#aff';
				var groups = mw.config.get( 'wgUserGroups' );
				var outdated = table.modules.filter( module => module.status === 'Outdated' );
				if ( groups.includes( 'autoconfirmed' ) && outdated.length > 0 ) {
					$button = $( '<button>Update all outdated</button>' ).on( 'click', function () {
						table.updateAllOutdated();
					} ).attr( 'title', 'Update all outdated modules, leaving Forked and Unrelated modules unaffected.' );
				}

			} else if ( module.status === 'Updated' ) {
				status = 'Updated';
				color = '#afa';
				title = 'The code of this module is identical to that of the master module.';

			} else if ( module.status === 'Outdated' ) {
				status = 'Outdated (' + module.revisionsBehind + ' revision' + ( module.revisionsBehind === 1 ? '' : 's' ) + ' behind)';
				color = '#ffa';
				title = 'This module is outdated with respect to the master module.';
				$button = $( '<button>Update</button>' ).on( 'click', function () {
					$( this ).closest( 'td' ).text( 'Loading diff...' );
					table.showDiff( module );
				} ).attr( 'title', 'Update the code of this module to the latest version, but first see the changes to be made.' );

			} else if ( module.status === 'Forked' ) {
				status = 'Forked (' + module.revisionsAhead + ' revision' + ( module.revisionsAhead === 1 ? '' : 's' ) + ' ahead)';
				color = '#faa';
				title = 'This module has diverged from the master module.';
				$button = $( '<button>Update</button>' ).on( 'click', function () {
					$( this ).closest( 'td' ).text( 'Loading diff...' );
					table.showDiff( module );
				} ).attr( 'title', 'Update this module to the latest version, but first see the changes to be made.' );
				$button2 = $( '<button>Analyze</button>' ).css( 'margin-left', '.4em' ).on( 'click', function () {
					$( this ).closest( 'td' ).text( 'Analyzing...' );
					table.analyze( module );
				} ).attr( 'title', 'See the changes since this module forked.' );

			} else if ( module.status === 'Unrelated' ) {
				status = 'Unrelated';
				color = '#faf';
				title = 'This module has no common history with the master module.';
				$button = $( '<button>Update</button>' ).on( 'click', function () {
					$( this ).closest( 'td' ).text( 'Loading diff...' );
					table.showDiff( module );
				} ).attr( 'title', 'Update the code of this module to the latest version, but first see the changes to be made.' );

			} else {
				status = module.status;
				color = '#ccc';
				$button = $( '<button>Retry</button>' ).on( 'click', function () {
					$( this ).closest( 'td' ).text( 'Reloading module...' );
					table.updateStatus( module );
				} );
			}

			var masterName = table.master.title.substring( table.master.title.indexOf( ':' ) + 1 );
			var moduleName = module.title.substring( module.title.indexOf( ':' ) + 1 );
			var $alert;
			if ( masterName !== moduleName ) {
				$alert = new OO.ui.IconWidget( {
					icon: 'alert',
					title: "This module is called '" + moduleName + "' rather than '" + masterName + "'. This may break dependencies between synchronized modules."
				} ).$element;
				$alert.css( {
					'cursor': 'help',
					'margin-left': '.4em',
					'vertical-align': 'top'
				} );
			}

			var $lock;
			if ( module.protection ) {
				$lock = new OO.ui.IconWidget( {
					icon: 'lock',
					title: "This page is protected. Only '" + module.protection + "' users may edit it."
				} ).$element;
				$lock.css( {
					'cursor': 'help',
					'margin-left': '.4em',
					'vertical-align': 'top'
				} );
			}

			var $link = $( '<a></a>' ).text( module.title ).attr( 'href', module.url );
			var $td1 = $( '<td></td>' ).text( module.wiki );
			var $td2 = $( '<td></td>' ).append( $link, $lock, $alert );
			var $td3 = $( '<td></td>' ).text( status ).attr( 'title', title ).css( { 'background-color': color, 'cursor': 'help' } );
			var $td4 = $( '<td></td>' ).append( $button, $button2 );
			var $row = $( '#synchronizer-table' ).find( '.' + module.wiki );
			$row.empty().append( $td1, $td2, $td3, $td4 );
		};
	
		this.showDiff = function ( module ) {
			var table = this;
			new mw.ForeignApi( module.api ).post( {
				formatversion: 2,
				action: 'compare',
				fromtitle: module.title,
				toslots: 'main',
				'totext-main': table.master.content
			} ).done( function ( data ) {

				// Prepare the message
				var diff = data.compare.body;
				var $message = $( '<table class="diff"><col class="diff-marker"><col class="diff-content"><col class="diff-marker"><col class="diff-content">' + diff + '</table>' );
				var options = {
					title: "Please review the changes you're about to make",
					size: 'larger',
					actions: [ {
						action: 'reject',
						label: 'Cancel',
						flags: 'safe'
					}, {
						action: 'accept',
						label: 'Publish',
						flags: 'primary'
					} ]
				};

				// Ask for confirmation
				OO.ui.confirm( $message, options ).done( function ( confirm ) {
					if ( confirm ) {
						table.update( module );
				    } else {
			    		table.updateRow( module );
				    }
				} );
			} );
		};

		this.analyze = function ( module ) {
			var table = this;
			new mw.ForeignApi( module.api ).get( {
				formatversion: 2,
				action: 'compare',
				fromrev: module.forkedRevision,
				torev: module.lastrevid,
			} ).done( function ( data ) {

				// Prepare the message
				var diff = data.compare.body;
				var title = 'About this fork';
				var caption = module.revisionsToMaster + ' revision' + ( module.revisionsToMaster === 1 ? '' : 's' ) + ' to master since the fork happened.';
				caption += '<br>' + module.revisionsAhead + ' revision' + ( module.revisionsAhead === 1 ? '' : 's' ) + ' to this module since the fork happened, shown below:';
				var $message = $( '<table class="diff"><caption>' + caption + '</caption><col class="diff-marker"><col class="diff-content"><col class="diff-marker"><col class="diff-content">' + diff + '</table>' );

				// Open the dialog
				OO.ui.alert( $message, { title: title, size: 'larger' } ).done( function () {
					table.updateRow( module );
				} );
			} );
		};

		this.update = function ( module ) {
			var table = this;
			module.status = 'Updating...';
			table.updateRow( module );
			return new mw.ForeignApi( module.api ).edit( module.title, function ( revision ) {
				var master = 'd:Special:GoToLinkedPage/' + table.master.wiki + '/' + table.entity;
				var summary = 'Update from [[' + master + '|master]] using [[mw:Synchronizer| #Synchronizer]]';
				return {
					text: table.master.content,
					summary: summary,
					assert: 'user'
				};
			} ).done( function ( data ) {
				//console.log( data );
				if ( data.result === 'Success' ) {
					module.status = 'Updated';
					module.content = table.master.content;
				} else if ( data.captcha ) {
					module.status = 'Failed captcha';
				} else {
					module.status = 'Unknown';
				}
				table.updateRow( module );
			} ).fail( function ( error ) {
				//console.log( error );
				switch ( error ) {
					case 'protectednamespace-interface':
					case 'protectednamespace':
					case 'customcssjsprotected':
					case 'cascadeprotected':
					case 'protectedpage':
					case 'permissiondenied':
						module.status = 'No permission';
						break;
					case 'assertuserfailed':
						module.status = 'Not logged-in';
						break;
					default:
						module.status = 'Failed';
						break;
				}
				table.updateRow( module );
			} );
		};
	
		this.updateAllOutdated = function () {
			var table = this;
			var outdated = table.modules.filter( module => module.status === 'Outdated' );
			var message = "You're about to update " + outdated.length + " module" + ( outdated.length === 1 ? '' : 's' ) + ". If you proceed there will be no further confirmation or diff shown. The modules will be updated immediately.";
			var options = {
				title: 'Warning!',
				actions: [ {
					action: 'reject',
					label: 'Cancel',
					flags: 'safe'
				}, {
					action: 'accept',
					label: 'Proceed',
					flags: 'primary'
				} ]
			};
			OO.ui.confirm( message, options ).done( function ( confirm ) {
				if ( confirm ) {
					outdated.forEach( table.update, table );
				}
			} );
		};

		this.makeTable = function () {
			var table = this;
			var $table = $( '<table></table>' ).addClass( 'wikitable synchronizer-table' );

			// Make header
			var $row = $( '<tr></tr>' );
			var $th1 = $( '<th></th>' ).text( 'Wiki' );
			var $th2 = $( '<th></th>' ).text( 'Title' );
			var $th3 = $( '<th></th>' ).text( 'Status' );
			var $th4 = $( '<th></th>' ).text( 'Action' );
			$row.append( $th1, $th2, $th3, $th4 );
			$table.append( $row );

			// Make master row
			$row = $( '<tr></tr>' ).addClass( 'master' ).addClass( table.master.wiki );
			var title = 'This is the master module. The status of all other modules is determined by comparison to it.';
			var $link = $( '<a></a>' ).text( table.master.title ).attr( 'href', table.master.url );
			var $td1 = $( '<td></td>' ).text( table.master.wiki );
			var $td2 = $( '<td></td>' ).html( $link );
			var $td3 = $( '<td></td>' ).text( 'Master' ).attr( 'title', title ).css( { 'background-color': '#aff', 'cursor': 'help' } );
			var $td4 = $( '<td></td>' );
			$row.append( $td1, $td2, $td3, $td4 );
			$table.append( $row );

			// Make empty rows for the rest of the modules
			for ( var module of table.modules ) {
				$row = $( '<tr></tr>' ).addClass( module.wiki );
				$link = $( '<a></a>' ).text( module.title ).attr( 'href', module.url );
				$td1 = $( '<td></td>' ).text( module.wiki );
				$td2 = $( '<td></td>' ).html( $link );
				$td3 = $( '<td></td>' ).text( 'Loading...' );
				$td4 = $( '<td></td>' );
				$row.append( $td1, $td2, $td3, $td4 );
				$table.append( $row );
			}

			// Add to DOM
			$( '#synchronizer-table' ).html( $table );
		};

		/**
		 * Throw table-level error message
		 */
		this.error = function ( message ) {
			$( '#synchronizer-table' ).addClass( 'error' ).html( message );
		};
	},

	/**
	 * Throw Synchronizer-level error message
	 */
	error: function ( message ) {
		$( '#synchronizer' ).addClass( 'error' ).html( message );
	}
};

mw.loader.using( [
	'oojs-ui-core',
	'oojs-ui-widgets',
	'oojs-ui-windows',
	'oojs-ui.styles.icons-alerts',
	'oojs-ui.styles.icons-moderation',
	'mediawiki.diff.styles',
	'mediawiki.ForeignApi',
], Synchronizer.init );