MediaWiki:Gadget-Synchronizer.js
Appearance
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 );