MediaWiki:Gadget-Synchronizer.js

/**
 * 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
 */
const Synchronizer = {

	userAgent: 'Synchronizer/1.1 (https://www.mediawiki.org/wiki/User:Sophivorus)',

	init() {

		// Make sure the user is logged in
		if ( !mw.config.get( 'wgUserName' ) ) {
			const page = mw.config.get( 'wgPageName' );
			const 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() {

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

		// Define elements
		const entityInput = new OO.ui.TextInputWidget( { id: 'synchronizer-input-entity', required: true, value: entity, placeholder: 'Q52428273' } );
		const masterInput = new OO.ui.TextInputWidget( { id: 'synchronizer-input-master', required: true, value: master, placeholder: 'enwiki' } );
		const buttonInput = new OO.ui.ButtonInputWidget( { label: 'Load', flags: [ 'primary', 'progressive' ] } );
		const 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' } );
		const 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' } );
		const buttonLayout = new OO.ui.FieldLayout( buttonInput );
		const layout = new OO.ui.HorizontalLayout( { id: 'synchronizer-form', items: [ entityLayout, masterLayout, buttonLayout ] } );

		// CSS tweaks
		entityInput.$element.css( { maxWidth: 150 } );
		masterInput.$element.css( { maxWidth: 150 } );
		buttonInput.$element.css( { position: 'relative', top: '26px' } );

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

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

	initTable() {
		const entity = $( '#synchronizer-input-entity input' ).val();
		const 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
		const $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
		new Synchronizer.Table( entity, master );
	},

	Table: class {

		constructor( entity, master ) {

			// Track usage
			mw.track( 'stats.mediawiki_gadget_Synchronizer_total', 1, { entity: entity } );

			// Set the ID of the Wikidata entity
			this.entity = entity;

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

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

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

		async getWikidataData() {
			try {
				const api = new mw.ForeignApi( '//www.wikidata.org/w/api.php', { userAgent: Synchronizer.userAgent } );
				const data = await api.get( {
					action: 'wbgetentities',
					props: 'info|sitelinks/urls',
					normalize: 1,
					ids: this.entity
				} );

				const entity = Object.values( data.entities )[0];
				const sitelinks = entity.sitelinks;
				const sitelink = sitelinks[ this.master.wiki ]; // Save the master sitelink
				delete sitelinks[ this.master.wiki ]; // Then remove it
				if ( $.isEmptyObject( sitelinks ) ) {
					return this.error( 'No pages associated to ' + this.entity );
				}

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

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

				this.makeTable();
				this.getMasterData();

			} catch ( error ) {
				this.error( error.message );
			}
		}

		async getMasterData() {
			const master = this.master;
			const api = new mw.ForeignApi( master.api, { userAgent: Synchronizer.userAgent } );
			const data = await api.get( {
				formatversion: 2,
				action: 'query',
				prop: 'revisions',
				rvprop: 'sha1|content',
				rvslots: 'main',
				titles: master.title
			} );

			const revision = data.query.pages[0].revisions[0];
			master.sha1 = revision.sha1;
			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
			const data2 = await api.get( {
				formatversion: 2,
				action: 'query',
				prop: 'revisions',
				rvprop: 'sha1',
				rvlimit: 'max',
				titles: master.title
			} );
			const revisions = data2.query.pages[0].revisions;
			master.hashes = revisions.map( revision => revision.sha1 );

			// Get and update the status of all the modules sequentially,
			// to honor https://www.mediawiki.org/wiki/API:Etiquette
			for ( const module of this.modules ) {
				await this.updateStatus( module );
			}

			// Finally, after the last one, update the master row
			this.updateRow( master );
		}

		async updateStatus( module ) {
			const api = new mw.ForeignApi( module.api, { userAgent: Synchronizer.userAgent } );
			const data = await api.get( {
				formatversion: 2,
				action: 'query',
				prop: 'revisions|info',
				rvprop: 'sha1',
				inprop: 'protection',
				meta: 'siteinfo',
				siprop: 'namespaces',
				titles: module.title
			} );

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

			// Figure out the status
			const revision = page.revisions[0];
			module.lastrevid = page.lastrevid;
			module.sha1 = revision.sha1;
			if ( module.sha1 === this.master.sha1 ) {
				module.status = 'Updated';
				this.updateRow( module );
			} else {
				const revisionsBehind = this.master.hashes.indexOf( module.sha1 );
				if ( revisionsBehind > -1 ) {
					module.status = 'Outdated';
					module.revisionsBehind = revisionsBehind;
					this.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
					const data = await api.get( {
						formatversion: 2,
						action: 'query',
						prop: 'revisions',
						rvprop: 'sha1|ids',
						rvlimit: 'max',
						titles: module.title
					} );
					const revisions = data.query.pages[0].revisions;
					let revisionsAhead = 0; // Number of revisions to the module since it forked
					let revisionsToMaster; // Number of revisions to master since the module forked
					let revision;
					for ( revision of revisions ) {
						revisionsAhead++;
						revisionsToMaster = this.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
					}
					this.updateRow( module );
				}
			}
		}

		updateRow( module ) {
			const table = this;
			let status, color, title, $button, $button2;

			if ( module.status === 'Master' ) {
				status = 'Master';
				color = '#aff';
				const groups = mw.config.get( 'wgUserGroups' );
				const outdated = table.modules.filter( module => module.status === 'Outdated' );
				if ( groups.includes( 'autoconfirmed' ) && outdated.length > 0 ) {
					$button = $( '<button>Update all outdated</button>' ).on( 'click', () => {
						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', event => {
					event.target.closest( 'td' ).textContent = 'Loading diff...';
					table.compare( module );
				} ).attr( 'title', 'Compare the code of this module with the master version, and then optionally update it.' );

			} 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', event => {
					event.target.closest( 'td' ).textContent = 'Loading diff...';
					table.compare( module );
				} ).attr( 'title', 'Compare the code of this module with the master version, and then optionally update it.' );
				$button2 = $( '<button>Analyze</button>' ).css( { marginLeft: '.4em' } ).on( 'click', event => {
					event.target.closest( 'td' ).textContent = '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', event => {
					event.target.closest( 'td' ).textContent = 'Loading diff...';
					table.compare( module );
				} ).attr( 'title', 'Compare the code of this module with the master version, and then optionally update it.' );

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

			const masterName = table.master.title.substring( table.master.title.indexOf( ':' ) + 1 );
			const moduleName = module.title.substring( module.title.indexOf( ':' ) + 1 );
			let $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',
					marginLeft: '.4em',
					verticalAlign: 'top'
				} );
			}

			let $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',
					marginLeft: '.4em',
					verticalAlign: 'top'
				} );
			}

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

			// Prepare the message
			const diff = data.compare.body;
			const $message = $( '<table class="diff"><col class="diff-marker"><col class="diff-content"><col class="diff-marker"><col class="diff-content">' + diff + '</table>' );
			const 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
			const confirm = await OO.ui.confirm( $message, options );
			if ( confirm ) {
				table.update( module );
			} else {
				table.updateRow( module );
			}
		}

		async analyze( module ) {
			const data = await new mw.ForeignApi( module.api, { userAgent: Synchronizer.userAgent } ).get( {
				formatversion: 2,
				action: 'compare',
				fromrev: module.forkedRevision,
				torev: module.lastrevid,
			} );

			// Prepare the message
			const diff = data.compare.body;
			const title = 'About this fork';
			const caption = module.revisionsToMaster + ' revision' + ( module.revisionsToMaster === 1 ? '' : 's' ) + ' to master since the fork happened.'
				+ '<br>' + module.revisionsAhead + ' revision' + ( module.revisionsAhead === 1 ? '' : 's' ) + ' to this module since the fork happened, shown below:';
			const $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
			await OO.ui.alert( $message, { title: title, size: 'larger' } );
			this.updateRow( module );
		}

		async update( module ) {
			const table = this;
			module.status = 'Updating...';
			table.updateRow( module );
			try {
				const data = await new mw.ForeignApi( module.api, { userAgent: Synchronizer.userAgent } ).edit( module.title, () => {
					const master = 'd:Special:GoToLinkedPage/' + table.master.wiki + '/' + table.entity;
					const summary = 'Update from [[' + master + '|master]] using [[mw:Synchronizer| #Synchronizer]]';
					return {
						text: table.master.content,
						summary: summary,
						assert: 'user'
					};
				} );
				//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 );
			} catch ( 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 );
			}
		}

		async updateAllOutdated() {
			const table = this;
			const outdated = table.modules.filter( module => module.status === 'Outdated' );
			const 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.";
			const options = {
				title: 'Warning!',
				actions: [ {
					action: 'reject',
					label: 'Cancel',
					flags: 'safe'
				}, {
					action: 'accept',
					label: 'Proceed',
					flags: 'primary'
				} ]
			};
			const confirm = await OO.ui.confirm( message, options );
			if ( confirm ) {
				for ( const module of outdated ) {
					await table.update( module );
				}
			}
		}

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

			// Make header
			let $row = $( '<tr></tr>' );
			const $th1 = $( '<th></th>' ).text( 'Wiki' );
			const $th2 = $( '<th></th>' ).text( 'Title' );
			const $th3 = $( '<th></th>' ).text( 'Status' );
			const $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 );
			const title = 'This is the master module. The status of all other modules is determined by comparison to it.';
			let $link = $( '<a></a>' ).text( table.master.title ).attr( 'href', table.master.url );
			let $td1 = $( '<td></td>' ).text( table.master.wiki );
			let $td2 = $( '<td></td>' ).html( $link );
			let $td3 = $( '<td></td>' ).text( 'Master' ).attr( 'title', title ).css( { backgroundColor: '#aff', cursor: 'help' } );
			let $td4 = $( '<td></td>' );
			$row.append( $td1, $td2, $td3, $td4 );
			$table.append( $row );

			// Make empty rows for the rest of the modules
			for ( const 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
		 */
		error( message ) {
			$( '#synchronizer-table' ).addClass( 'error' ).html( message );
		}
	},

	/**
	 * Throw Synchronizer-level error message
	 */
	error( 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 );