Nota: Después de publicar, quizás necesite actualizar la caché de su navegador para ver los cambios.

  • Firefox/Safari: Mantenga presionada la tecla Shift mientras pulsa el botón Actualizar, o presiona Ctrl+F5 o Ctrl+R (⌘+R en Mac)
  • Google Chrome: presione Ctrl+Shift+R (⌘+Shift+R en Mac)
  • Internet Explorer/Edge: mantenga presionada Ctrl mientras pulsa Actualizar, o presione Ctrl+F5
  • Opera: Presiona Ctrl+F5.
/**
 * 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.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: 'Q52428273' } );
		var masterInputWidget = new OO.ui.TextInputWidget( { id: 'synchronizer-input-master', required: true, value: master, placeholder: 'enwiki' } );
	 	var buttonInputWidget = new OO.ui.ButtonInputWidget( { label: 'Load', flags: [ 'primary', 'progressive' ] } );
		var entityFieldLayout = new OO.ui.FieldLayout( entityInputWidget, { label: 'Entity', align: 'top', help: 'Wikidata entity you want to synchronize, for example Q52428273 for Module:Excerpt' } );
		var masterFieldLayout = new OO.ui.FieldLayout( masterInputWidget, { label: 'Master', align: 'top', help: 'Wiki to be considered the master version, for example enwiki for Module:Excerpt' } );
		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 = $( '<div>Loading...</div>' ).attr( 'id', entity ).css( 'margin', '.5em 0' );
		$( '#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;

			// 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 = $( '<button>Update</button>' ).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>' ).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' ).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 = $( '<button>Update</button>' ).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 = $( '<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 = 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 = $( '<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: '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 += '<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]] #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 = $( '<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>' );
			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>' );
			var groups = mw.config.get( 'wgUserGroups' );
			if ( groups.includes( 'autoconfirmed' ) ) {
				var $button = $( '<button>Update all outdated</button>' ).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 = $( '<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
			table.$div.html( $table );
		};

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