MediaWiki:Synchronizer.js

/** * Synchronizer is a tool for synchronizing Lua modules and other pages across Wikimedia wikis * Documentation: https://www.mediawiki.org/wiki/Synchronizer * Author: User:Sophivorus * License: CC-BY-SA-3.0 */ window.Synchronizer = {

init: function { Synchronizer.initTable; Synchronizer.initDiffDialog; Synchronizer.initForkDialog; $( window ).on( 'hashchange', Synchronizer.initTable ); },

initTable: function { var id = location.hash; if ( !id ) { return console.log( 'Hash in URL not found.'); }		var $div = $( id ); if ( !$div.length ) { return console.log( 'Div with ID ' + id + ' not found.'); }		var table = new Synchronizer.Table( id ); table.init; },

Table: function ( id ) {

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

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

/**		 * Data of the master module * @type {Object} Map from data key to data value */		this.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;

table.$div.text( 'Loading...' );

// Get Wikidata entity ID			var entity = table.$div.data( 'entity' ).trim; if ( !entity ) { return table.error( 'Wikidata entity not set.' ); }			table.entity = entity;

// Get master wiki ID			var master = table.$div.data( 'master' ); if ( !master ) { master = mw.config.get( 'wgWikiID' ); }			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; new mw.ForeignApi( module.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 );				var page = data.query.pages[0];				var revision = page.revisions[0];				module.lastrevid = page.lastrevid;				module.sha1 = revision.sha1;				if ( module.sha1 === table.master.sha1 ) {					module.status = 'Updated';				} else {					var index = table.master.hashes.indexOf( module.sha1 );					if ( index === -1 ) {						module.status = 'Forked';					} else {						module.status = index + ' revision' + ( index === 1 ? '' : 's' ) + ' behind';					}				}				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;					}				}				table.updateRow( module );			} ); };

this.updateRow = function ( module ) { var table = this; var color, title, $button, $button2; if ( module.status === 'Updated' ) { color = '#afa'; title = 'The code of this module is the same as the master module.'; } else if ( module.status.includes( 'behind' ) ) { color = '#ffa'; title = 'The code of this module is behind that of 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' ) { color = '#faa'; title = 'The code of this module has diverged from 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.' ); $button2 = $( ' Diff since fork ' ).css( 'margin-left', '.4em' ).click( function {					$( this ).closest( 'td' ).text( 'Loading diff...' );					table.showFork( module );				} ).attr( 'title', 'See the changes since this module was first forked.' ); }			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( module.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 ) {				var diff = data.compare.body;				var windowManager = new OO.ui.WindowManager;				$( document.body ).append( windowManager.$element );				var dialog = new Synchronizer.DiffDialog( { data: { 'diff': diff, 'table': table, 'module': module, } } );				windowManager.addWindows( [ dialog ] );				windowManager.openWindow( dialog );			} ); };

this.showFork = function ( module ) { var table = this; var api = new mw.ForeignApi( module.api ); 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 forkrevid; // Will store the id of the revision that forked				var count = 0;				for ( var revision of revisions ) {					count++;					forkrevid = revision.revid;					var index = table.master.hashes.indexOf( revision.sha1 );					if ( index > -1 ) {						break;					}				}				var caption = 'Forked ' + count + ' revision' + ( count === 1 ? '' : 's' ) + ' back';				if ( count === revisions.length ) {					caption = 'This module has no common history with master.';				}				api.get( { 'formatversion': 2, 'action': 'compare', 'fromrev': forkrevid, 'torev': module.lastrevid, } ).done( function ( data ) { var diff = data.compare.body; var windowManager = new OO.ui.WindowManager; $( document.body ).append( windowManager.$element ); var dialog = new Synchronizer.ForkDialog( { data: {						'diff': diff,						'table': table,						'module': module,						'caption': caption,					} } ); windowManager.addWindows( [ dialog ] ); windowManager.openWindow( dialog ); } ).fail( console.log );			} ); };

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.updateAllNonForked = function {			var table = this;			var count = 0;			table.modules.forEach( function ( module ) { if ( module.status.includes( 'behind' ) ) { count++; }			} );			var confirm = window.confirm( "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." );			if ( !confirm ) {				return;			}			table.modules.forEach( function ( module ) { if ( module.status.includes( 'behind' ) ) { table.update( module ); }			} );		};

this.makeTable = function { var table = this; var $table = $( ' ' ); this.$body.append( this.content.$element ); };		Synchronizer.DiffDialog.prototype.getActionProcess = function ( action ) { if ( action ) { var dialog = this; var table = dialog.data.table; var module = dialog.data.module; if ( action === 'save' ) { table.update( module ); }			   if ( action === 'cancel' ) { table.updateRow( module ); }		       return new OO.ui.Process( function  {		            dialog.close;		        } ); }		   return Synchronizer.DiffDialog.super.prototype.getActionProcess.call( this, action ); };	},

ForkDialog: function ( config ) { Synchronizer.ForkDialog.super.call( this, config ); },

initForkDialog: function { OO.inheritClass( Synchronizer.ForkDialog, OO.ui.ProcessDialog ); Synchronizer.ForkDialog.static.name = 'diff'; Synchronizer.ForkDialog.static.title = 'Changes since this module forked'; Synchronizer.ForkDialog.static.size = 'larger'; Synchronizer.ForkDialog.static.actions = [ { action: 'back', label: 'Back', flags: 'safe' } ];		Synchronizer.ForkDialog.prototype.initialize = function { Synchronizer.ForkDialog.super.prototype.initialize.apply( this, arguments ); this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } ); this.content.$element.append( ' ' ); this.$body.append( this.content.$element ); };		Synchronizer.ForkDialog.prototype.getActionProcess = function ( action ) { if ( action ) { var dialog = this; var table = dialog.data.table; var module = dialog.data.module; if ( action === 'back' ) { table.updateRow( module ); }		       return new OO.ui.Process( function  {		            dialog.close;		        } ); }		   return Synchronizer.ForkDialog.super.prototype.getActionProcess.call( this, action ); };	} };

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 );