MediaWiki: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 */ window.Synchronizer = {

/**	 * Interface messages * Localization not needed because probably all Lua developers know English * @todo Finish all messages */	messages: { 'synchronizer-input-entity-label': 'Module entity', 'synchronizer-input-entity-help': 'Wikidata entity of the module to synchronize, for example Q52428273 for Module:Excerpt', 'synchronizer-input-entity-placeholder': 'Q52428273', 'synchronizer-input-master-label': 'Master wiki', 'synchronizer-input-master-help': 'Wiki of the master version of the module, for example enwiki for Module:Excerpt', 'synchronizer-input-master-placeholder': 'enwiki', 'synchronizer-load-button-label': 'Load', 'synchronizer-loading': 'Loading...', },

init: function { mw.messages.set( Synchronizer.messages ); Synchronizer.makeForm; Synchronizer.initTable; },

makeForm: function {

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

// Define elements var entityInputWidget = new OO.ui.TextInputWidget( { id: 'synchronizer-input-entity', required: true, value: entity, placeholder: mw.msg( 'synchronizer-input-entity-placeholder' ) } ); var masterInputWidget = new OO.ui.TextInputWidget( { id: 'synchronizer-input-master', required: true, value: master, placeholder: mw.msg( 'synchronizer-input-master-placeholder' ) } ); var buttonInputWidget = new OO.ui.ButtonInputWidget( { label: mw.msg( 'synchronizer-load-button-label' ), flags: [ 'primary', 'progressive' ] } ); var entityFieldLayout = new OO.ui.FieldLayout( entityInputWidget, { label: mw.msg( 'synchronizer-input-entity-label' ), align: 'top', help: mw.msg( 'synchronizer-input-entity-help' ) } ); var masterFieldLayout = new OO.ui.FieldLayout( masterInputWidget, { label: mw.msg( 'synchronizer-input-master-label' ), align: 'top', help: mw.msg( 'synchronizer-input-master-help' ) } ); var buttonFieldLayout = new OO.ui.FieldLayout( buttonInputWidget ); var layout = new OO.ui.HorizontalLayout( { id: 'synchronizer-form', items: [ entityFieldLayout, masterFieldLayout, buttonFieldLayout ] } );

// CSS adjustments buttonInputWidget.$element.css( { 'position': 'relative', 'bottom': '1px' } ); entityFieldLayout.$element.css( 'max-width', 150 ); masterInputWidget.$element.css( 'max-width', 150 );

// Bind events buttonInputWidget.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 $( '#' + entity ).remove; // Remove any previous one var $div = $( ' ' ).attr( 'id', entity ).css( 'margin', '.5em 0' ).text( mw.msg( 'synchronizer-loading' ) ); $( '#synchronizer-form' ).after( $div );

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

Table: function ( $div, entity, master ) {

/**		 * Wrapper div created by Module:Synchronizer that will hold the table * @type {Object} jQuery object containing the wrapper div */		this.$div = $div;

/**		 * 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;

if ( !mw.config.get( 'wgUserName' ) ) { var page = mw.config.get( 'wgPageName' ); var href = mw.util.getUrl( 'Special:Login', { returnto: page } ); return table.error( 'You need to log in to use Synchronizer' ); }

// Get master wiki ID			table.master = { wiki: master, status: 'Master' };

// Get 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 ) {				var entity = Object.values( data.entities )[0];				if ( entity.hasOwnProperty( 'missing' ) ) {					return table.error( 'No Wikidata entity associated to ' + table.entity );				}				var sitelink = entity.sitelinks[ table.master.wiki ]; // Save the master sitelink				delete entity.sitelinks[ table.master.wiki ]; // Then remove it				if ( !entity.sitelinks ) {					return table.error( 'No wiki 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 entity.sitelinks ) { sitelink = entity.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; } );		};

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; } );					for ( var module of table.modules ) {						table.updateStatus( module );					}				} ); } );		},

this.updateStatus = function ( module ) { var table = this; var api = new mw.ForeignApi( module.api ); 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 = module.status; var color, title, $button, $button2; if ( module.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 = $( ' Update ' ).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 = $( ' Update ' ).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 = $( ' Analyze ' ).css( 'margin-left', '.4em' ).click( function {					$( this ).closest( 'td' ).text( 'Analyzing...' );					table.analyze( module );				} ).attr( 'title', 'See the changes since this module forked.' ); } else if ( module.status === 'Unrelated' ) { color = '#faf'; title = 'This module has no common history with the master module.'; $button = $( ' Update ' ).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.' ); }			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 = $( '' ).text( module.title ).attr( 'href', module.url ); var $td1 = $( ' ' ).text( module.wiki ); var $td2 = $( ' ' ).append( $link, $lock, $alert ); var $td3 = $( ' ' ).text( status ).attr( 'title', title ).css( { 'background-color': color, 'cursor': 'help' } ); var $td4 = $( ' ' ).append( $button, $button2 ); var $row = table.$div.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 = $( ' ' ); var options = { title: "Please review the changes you're about to make", size: 'larger', actions: [ { action: 'reject', label: 'Cancel', flags: 'safe' }, {						action: 'accept', label: 'Save', 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 += ' ' + module.revisionsAhead + ' revision' + ( module.revisionsAhead === 1 ? '' : 's' ) + ' to this module since the fork happened, shown below:'; var $message = $( ' ' );

// 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 using Synchronizer #synchronizer';				return {					text: table.master.content,					summary: summary,					assert: 'user'				};			} ).done( function ( data ) {				//console.log( data );				module.status = 'Updated';				module.content = table.master.content;				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 count = 0;			table.modules.forEach( function ( module ) { if ( module.status === 'Outdated' ) { count++; }			} );			var message = "You're about to update " + count + " module" + ( count === 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 ) { table.modules.forEach( function ( module ) {						if ( module.status === 'Outdated' ) {							table.update( module );						}					} ); }			} );		};

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

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

// Make master row $row = $( ' ' ); var title = 'This is the master module. The status of all other modules is determined by comparison to it.'; var $link = $( '' ).text( table.master.title ).attr( 'href', table.master.url ); var $td1 = $( ' ' ).text( table.master.wiki ); var $td2 = $( ' ' ).html( $link ); var $td3 = $( ' ' ).text( 'Master' ).attr( 'title', title ).css( { 'background-color': '#aff', 'cursor': 'help' } ); var $td4 = $( ' ' ); var groups = mw.config.get( 'wgUserGroups' ); if ( groups.includes( 'autoconfirmed' ) ) { var $button = $( ' Update all outdated ' ).click( function {					table.updateAllOutdated;				} ).attr( 'title', 'Update all outdated modules, leaving Forked and Unrelated modules unaffected.' ); $td4.append( $button ); }			$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 = $( ' ' ).addClass( module.wiki ); $link = $( '' ).text( module.title ).attr( 'href', module.url ); $td1 = $( ' ' ).text( module.wiki ); $td2 = $( ' ' ).html( $link ); $td3 = $( ' ' ).text( 'Loading...' ); $td4 = $( ' ' ); $row.append( $td1, $td2, $td3, $td4 ); $table.append( $row ); }

// Add to DOM table.$div.html( $table ); };

/**		 * Make error message */		this.error = function ( message ) { var table = this; if ( table.$div && table.$div.length ) { table.$div.addClass( 'error' ).html( message ); } else { console.log( 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 );