User:DannyS712/SortedPropertiesUpdater.js

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)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
// <nowiki>
// Quick script to Simplify updating MediaWiki:Wikibase-SortedProperties
// @author DannyS712
/* jshint maxerr: 999 */
$(() => {
const SortedPropertiesUpdater = {};
window.SortedPropertiesUpdater = SortedPropertiesUpdater;

// Included in the edit summary
SortedPropertiesUpdater.version = '1.2';

SortedPropertiesUpdater.init = function () {
	window.document.title = 'SortedProperties updater';
	$( '#firstHeading' ).text( 'SortedProperties updater' );
	mw.loader.using(
		[ 'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows', 'mediawiki.api', 'mediawiki.util' ],
		SortedPropertiesUpdater.run
	);
};

SortedPropertiesUpdater.onErrHandler = function () {
	// Shared error handler
	alert( 'Something went wrong' );
	console.log( arguments );
};

SortedPropertiesUpdater.startedProcessing = false;

// Map technical name to that for the section `Other properties with datatype "?"`
// Key: string data type from api (was stored in propertyDataTypesCache)
// Value: string data type for section name
SortedPropertiesUpdater.datatypeMap = {
	'commonsMedia': 'Commons media file',
	// Omitting external id intentionally, handled separately
	'geo-shape': 'geo-shape',
	'globe-coordinate': 'geographic coordinates',
	'math': 'math',
	'monolingualtext': 'monolingual text',
	'musical-notation': 'musical-notation',
	'quantity': 'quantity',
	'string': 'string',
	'tabular-data': 'tabular data',
	'time': 'point in time',
	'url': 'url',
	'wikibase-form': 'wikibase-form',
	'wikibase-item': 'item',
	'wikibase-lexeme': 'wikibase-lexeme',
	'wikibase-property': 'wikibase-property',
	'wikibase-sense': 'wikibase-sense'
};

SortedPropertiesUpdater.run = function () {
	var latestPropertyWidget = new OO.ui.TextInputWidget();
	var latestPropertyLayout = new OO.ui.FieldLayout(
		latestPropertyWidget,
		{ label: 'Latest property (in the form `xxx` for Pxxx)' }
	);
	var autoUpdateWidget = new OO.ui.CheckboxInputWidget();
	var autoUpdateLayout = new OO.ui.FieldLayout(
		autoUpdateWidget,
		{ label: 'Automatically update (BE CAREFUL AND DOUBLE CHECK)' }
	);
	var submit = new OO.ui.ButtonInputWidget( { 
		label: 'View updates',
		flags: [ 'primary', 'progressive' ]
	} );
	submit.on( 'click', function () {
		// CONVERT YOUR WIDGETS TO INPUT
		var submittedValues = {
			latestProperty: latestPropertyWidget.getValue(),
			autoUpdate: autoUpdateWidget.isSelected()
		};
		console.log( submittedValues );
		SortedPropertiesUpdater.onSubmit( submittedValues );
	} );
	$( window ).on( 'keypress', function ( e ) {
		// press enter to start
		if ( e.which == 13 ) {
			submit.simulateLabelClick();
		}
	} );
	
	var fieldSet = new OO.ui.FieldsetLayout( { 
		label: $( '<span>' )
			.append( 'View and implement updates needed for ' )
			.append(
				$( '<a>' )
					.attr( 'href', '/wiki/MediaWiki:Wikibase-SortedProperties' )
					.attr( 'target', '_blank' )
					.text( '[[MediaWiki:Wikibase-SortedProperties]]' ),
				$( '<small>' ).append(
					' (',
					$( '<a>' )
						.attr( 'href', '/wiki/MediaWiki:Wikibase-SortedProperties?diff=cur&oldid=prev&diffonly=true' )
						.attr( 'target', '_blank' )
						.text( 'last edit' ),
					') (',
					$( '<a>' )
						.attr( 'href', '/wiki/User:DannyS712/SortedPropertiesUpdater.js' )
						.attr( 'target', '_blank' )
						.text( '[[User:DannyS712/SortedPropertiesUpdater.js]]' ),
					')'
				).css( 'font-weight', 'normal' )
			)
	} );
	
	fieldSet.addItems( [
		latestPropertyLayout,
		autoUpdateLayout,
		new OO.ui.FieldLayout( submit )
	] );

	var $results = $( '<div>' )
		.attr( 'id', 'SortedPropertiesUpdater-results' );
	$( '#mw-content-text' ).empty().append(
		fieldSet.$element,
		$( '<hr>' ),
		$results
	);
	
	SortedPropertiesUpdater.autoFillLatestProperty( latestPropertyWidget );
};

SortedPropertiesUpdater.autoFillLatestProperty = function ( inputWidget ) {
	SortedPropertiesUpdater.getLatestProperty().then(
		function ( latestProperty ) {
			console.log( 'Got latest property: ' + latestProperty );
			if ( SortedPropertiesUpdater.startedProcessing === false ) {
				inputWidget.setValue( latestProperty );
				console.log( 'Updated widget accordingly' );
			}
		}
	);
};
SortedPropertiesUpdater.getLatestProperty = function () {
	return new Promise(
		function ( resolve ) {
			new mw.Api().get( {
				action: 'query',
				format: 'json',
				formatversion: 2,
				list: 'recentchanges',
				rcdir: 'older',
				rcnamespace: 120, // property
				rcprop: 'title',
				rclimit: 1, // just the latest
				rctype: 'new' // looking for new property creations
			} ).then(
				function ( response ) {
					console.log( response );
					var propertyTitle = response.query.recentchanges[0].title;
					var propertyId = propertyTitle.replace( 'Property:P', '' );
					resolve( propertyId );
				},
				SortedPropertiesUpdater.onErrHandler
			);
		}
	);
};

// Calls itself recursively until everything is retrieved
SortedPropertiesUpdater.getRawLabels = function ( propertyIds ) {
	// TODO handle non-admins running this script with 50 instead of 500
	return new Promise(
		function ( resolve ) {
			new mw.Api().get( {
				action: 'wbgetentities',
				formatversion: 2,
				ids: propertyIds.slice( 0, 500 ),
				languages: 'en',
				props: 'labels'
			} ).then(
				function ( response ) {
					console.log( response );
					var labels = response.entities;
					if ( propertyIds.length > 500 ) {
						// recursive
						SortedPropertiesUpdater.getRawLabels( propertyIds.slice( 500 ) ).then(
							function ( extraLabels ) {
								var bothLabels = $.extend( {}, labels, extraLabels );
								resolve( bothLabels );
							},
							SortedPropertiesUpdater.onErrHandler
						);
					} else {
						resolve( labels );
					}
				},
				SortedPropertiesUpdater.onErrHandler
			);
		}
	);
};

SortedPropertiesUpdater.propertyDataTypesCache = {};
SortedPropertiesUpdater.getRowsForProperties = function ( labelInfo ) {
	var properties = Object.keys( labelInfo );
	var rows = [];
	
	var propertyInfo;
	var propertyRow;
	properties.forEach( function ( propertyId ) {
		propertyInfo = labelInfo[ propertyId ];
		if ( propertyInfo.missing !== undefined ) {
			return;
		}
		propertyRow = propertyInfo.id + ' (' + propertyInfo.labels.en.value + ')';
		rows.push( propertyRow );
		
		// For later
		SortedPropertiesUpdater.propertyDataTypesCache[ propertyInfo.id ] = propertyInfo.datatype;
	} );
	console.log( rows );
	return rows;
};

SortedPropertiesUpdater.currentTextCache = false;

SortedPropertiesUpdater.getCurrentSortedProperties = function () {
	return new Promise(
		function ( resolve ) {
			new mw.Api().get( {
				action: 'query',
				prop: 'revisions',
				rvprop: 'content',
				rvslots: 'main',
				titles: 'MediaWiki:Wikibase-SortedProperties',
				formatversion: 2
			} ).then(
				function ( response ) {
					console.log( response );
					var text = response.query.pages[0].revisions[0].slots.main.content;
					SortedPropertiesUpdater.currentTextCache = text; // for use later
					var rows = text.split( '\n' );
					rows = rows.filter(
						function ( row ) {
							// Ignore the section headings, etc.
							// Some entries start with *, others with #
							return ( row.startsWith( '* P' ) || row.startsWith( '# P' ) );
						}
					);
					rows = rows.map(
						function ( row ) {
							// remove the first two characters, the * or # and the space
							return row.substring( 2 );
						}
					);
					resolve( rows );
				},
				SortedPropertiesUpdater.onErrHandler
			);
		}
	);
};

SortedPropertiesUpdater.compareWithCurrent = function ( goodRows, autoUpdate ) {
	SortedPropertiesUpdater.getCurrentSortedProperties().then( function( currentSortedProperties ) {
		console.log( currentSortedProperties );
		
		// Handle changes in labels first
		// Map of property id to label
		var currentLabels = {}, goodLabels = {};
		var rowInfo;
		currentSortedProperties.forEach(
			function ( currentRow ) {
				rowInfo = currentRow.match( /^(P\d+) \((.*?)\)$/ );
				if ( rowInfo ) {
					currentLabels[ rowInfo[1] ] = rowInfo[ 2 ];
				} else {
					console.log( 'Odd row in currentSortedProperties: ' + currentRow );
				}
			}
		);
		goodRows.forEach(
			function ( goodRow ) {
				rowInfo = goodRow.match( /^(P\d+) \((.*?)\)$/ );
				if ( rowInfo ) {
					goodLabels[ rowInfo[1] ] = rowInfo[ 2 ];
				} else {
					console.log( 'Odd row in goodRows: ' + goodRow );
				}
			}
		);
		
		var labelChanges = [];
		var labelChangesData = {};
		var rowsToRemove = [];
		var rowsToAdd = [];
		var rowsToAddData = {};
		var currentLabelForProperty;
		Object.keys( currentLabels ).forEach(
			function ( propertyId ) {
				currentLabelForProperty = currentLabels[ propertyId ];
				if ( goodLabels[ propertyId ] === undefined ) {
					// No good label for this property, so it was deleted
					rowsToRemove.push( propertyId + ' (' + currentLabelForProperty + ')' );
					return;
				}
				
				// There is a label for this property, compare it
				if ( goodLabels[ propertyId ] !== currentLabelForProperty ) {
					labelChangesData[ propertyId ] = [ currentLabelForProperty, goodLabels[ propertyId ] ];
					labelChanges.push( propertyId + ': ' + currentLabelForProperty + ' => ' + goodLabels[ propertyId ] );
				}
			}
		);
		
		Object.keys( goodLabels ).forEach(
			function ( propertyId ) {
				// We have a good label. If there is no prior label, it means its a new property to add
				if ( currentLabels[ propertyId ] === undefined ) {
					rowsToAdd.push( propertyId + ' (' + goodLabels[ propertyId ] + ')' );
					rowsToAddData[ propertyId ] = goodLabels[ propertyId ];
				}
			}
		);
		
		var sortedRowsToAdd = SortedPropertiesUpdater.formatRowsToAdd( rowsToAddData );
		var $res = $( '#SortedPropertiesUpdater-results' );
		$res.append(
			$( '<p>' ).append( 'Checked against current rows. Results:' )
		);
		$res.append(
			$( '<pre>' ).text(
				'Rows to change:\n' + labelChanges.join( '\n' ) +
				'\n\n' +
				'Rows to remove:\n' + rowsToRemove.join( '\n' ) +
				'\n\n' +
				'Rows to add:\n' + rowsToAdd.join( '\n' ) +
				'\n\n' +
				'Sorted rows to add info:\n' + sortedRowsToAdd
			)
		);
		
		SortedPropertiesUpdater.makeNewContent( labelChangesData, rowsToAddData, rowsToRemove, autoUpdate );
	} );
};

SortedPropertiesUpdater.formatRowsToAdd = function ( rowsToAddData ) {
	var resultText = '';
	if ( rowsToAddData ) {
		// object with keys as property ids, values as the labels
		// use SortedPropertiesUpdater.propertyDataTypesCache
		var propertiesToAddByType = {};
		Object.keys( rowsToAddData ).forEach(
			function ( propertyIdToAdd ) {
				var propertyType = SortedPropertiesUpdater.propertyDataTypesCache[ propertyIdToAdd ];
				if ( propertiesToAddByType[ propertyType ] === undefined ) {
					propertiesToAddByType[ propertyType ] = [];
				}
				propertiesToAddByType[ propertyType ].push(
					'* ' + propertyIdToAdd + ' (' + rowsToAddData[ propertyIdToAdd ] + ')'
				);
			}
		);
		Object.keys( propertiesToAddByType ).forEach(
			function ( dataType ) {
				resultText = resultText + '; Data type ' + dataType + '\n' + propertiesToAddByType[ dataType ].join( '\n' ) + '\n';
			}
		);
	}
	return resultText;
};

SortedPropertiesUpdater.makeNewContent = function ( labelChangesData, rowsToAddData, rowsToRemove, autoUpdate ) {
	var newText = SortedPropertiesUpdater.currentTextCache;
	var replacementRegex;
	var replacementInfo;
	Object.keys( labelChangesData ).forEach(
		function ( propertyNeedingLabelChange ) {
			// key is the property id, value is an array of the current label and the new label
			replacementInfo = labelChangesData[ propertyNeedingLabelChange ];
			replacementRegex = new RegExp(
				propertyNeedingLabelChange +
				' \\(' + mw.util.escapeRegExp( replacementInfo[ 0 ] ) + '\\)\\n'
			);
			newText = newText.replace(
				replacementRegex,
				propertyNeedingLabelChange + ' (' + replacementInfo[ 1 ] + ')\n'
			);
		}
	);
	
	if ( Object.keys( rowsToAddData ).length > 0 ) {
		// filter out new external ids, those are sorted into alphabetical order section seperately
		var newExternalIds = [];
		Object.keys( rowsToAddData ).forEach(
			function ( propertyIdToAdd ) {
				var propertyType = SortedPropertiesUpdater.propertyDataTypesCache[ propertyIdToAdd ];
				if ( propertyType === 'external-id' ) {
					// add the # here so that we can be consistent in the handling with the existing entries
					newExternalIds.push( '# ' + propertyIdToAdd + ' (' + rowsToAddData[ propertyIdToAdd ] + ')' );
					delete rowsToAddData[ propertyIdToAdd ];
				}
			}
		);
		console.log( newExternalIds );
		// retrieve current external ids in alphabetical order
		var currentExternalIdsABCmatch = newText.match(
			/<!--External id alphabetical order start-->\n([\s\S]+?)\n<!--External id alphabetical order end-->/
		);
		if ( currentExternalIdsABCmatch && currentExternalIdsABCmatch[1] ) {
			var currentExternalIdsABC = currentExternalIdsABCmatch[1];
			var unsortedExternalIds = currentExternalIdsABC.split( '\n' );
			unsortedExternalIds = unsortedExternalIds.concat( newExternalIds );
			unsortedExternalIds.sort(
				function ( row1, row2 ) {
					row1Info = row1.match( /^# P\d+ \((.*?)\)$/ );
					row2Info = row2.match( /^# P\d+ \((.*?)\)$/ );
					if ( !row1Info || !row2Info ) {
						return 0;
					}
					return row1Info[1].localeCompare( row2Info[1] );
				}
			);
			var updatedSortedExternalIds = unsortedExternalIds.join( '\n' );
			newText = newText.replace(
				/<!--External id alphabetical order start-->\n([\s\S]+?)\n<!--External id alphabetical order end-->/,
				'<!--External id alphabetical order start-->\n' + updatedSortedExternalIds + '\n<!--External id alphabetical order end-->'
			);
		}
		
		if ( Object.keys( rowsToAddData ).length > 0 ) {
			// need check rowsToAddData again since maybe all of the new properties were external ids handled separately
			// Try to add to relevant sections by datatype
			Object.keys( rowsToAddData ).forEach(
				function ( propertyIdToAdd ) {
					var propertyType = SortedPropertiesUpdater.propertyDataTypesCache[ propertyIdToAdd ];
					var dataTypeInSection = SortedPropertiesUpdater.datatypeMap[ propertyType ];
					if ( !dataTypeInSection ) {
						// Anything I missed, or new properties being added...
						return;
					}
					var sectionRegex = new RegExp(
						'(=== Other properties with datatype "' + dataTypeInSection + '" ===(?:\\n\\* P\\d+ .*)+)'
					);
					var sectionMatch = newText.match( sectionRegex );
					if ( !sectionMatch || !sectionMatch[1] ) {
						// Missing section, or I messed up
						return;
					}
					// Add entry to relevant section
					newText = newText.replace(
						sectionRegex,
						sectionMatch[1] + '\n* ' + propertyIdToAdd + ' (' + rowsToAddData[ propertyIdToAdd ] + ')'
					);
					// Remove from unsorted
					delete rowsToAddData[ propertyIdToAdd ];
				}
			);
		}
		
		if ( Object.keys( rowsToAddData ).length > 0 ) {
			// need check rowsToAddData again since maybe all of the new properties were external ids handled separately, or
			// were properly sorted by section
			
			// the "Unsorted properties" heading might already be there, if so don't add another
			if ( !( newText.match( /==\s*Unsorted properties\s*==/i ) ) ) {
				// only 1 newline here, so that we can ensure there is always a newline before the new entries if there
				// was a heading already
				newText = newText + '\n\n== Unsorted properties ==\n';
			}
			newText = newText + '\n' + SortedPropertiesUpdater.formatRowsToAdd( rowsToAddData );
		}
	}

	// Automatically remove deleted properties
	var removalRegex;
	rowsToRemove.forEach(
		function ( deletedProperty ) {
			removalRegex = new RegExp(
				'\\n[*#] ' + mw.util.escapeRegExp( deletedProperty )
			);
			newText = newText.replace(
				removalRegex,
				""
			);
		}
	);
	
	$( '#SortedPropertiesUpdater-results' ).append(
		$( '<p>' ).append( 'An updated text is available to copy from the console' )
	);
	console.log( newText );
	
	if ( autoUpdate ) {
		SortedPropertiesUpdater.autoUpdate( newText );
	}
};

SortedPropertiesUpdater.autoUpdate = function ( newText ) {
	mw.notify( 'Automatically updating!', { autoHide: false } );
	var params = {
		action: 'edit',
		title: 'MediaWiki:Wikibase-SortedProperties',
		text: newText,
		summary: 'Automatically updating via [[User:DannyS712/SortedPropertiesUpdater.js]] (version ' + SortedPropertiesUpdater.version + ')',
		nocreate: true, // just in case
		assert: 'user', // non users shouldn't be able to edit MediaWiki namespace, but why not?
		format: 'json',
		formatversion: 2
	};
	console.log( params );
	new mw.Api().postWithEditToken( params ).then(
		function ( response ) {
			console.log( response );
			mw.notify( 'Finished auto update', { autoHide: false } );
		},
		SortedPropertiesUpdater.onErrHandler
	);
};

SortedPropertiesUpdater.onSubmit = function ( inputs ) {
	SortedPropertiesUpdater.startedProcessing = true;

	var $res = $( '#SortedPropertiesUpdater-results' );
	$res.empty();
	
	console.log( inputs );
	var latestProperty = inputs.latestProperty;
	
	$res.append(
		$( '<p>' ).append(
			'Checking the api for labels of properties up to P' + latestProperty
		)
	);
	
	// Array from P1 to P<latestProperty>
	var propertyIds = [];
	for ( var iii = 1; iii <= latestProperty; iii++ ) {
		propertyIds.push( 'P' + iii );
	}
	SortedPropertiesUpdater.getRawLabels( propertyIds ).then(
		function ( labelInfo ) {
			console.log( labelInfo );
			$res.append(
				$( '<p>' ).append( 'Api calls done, now converting to rows' )
			);
			var goodRows = SortedPropertiesUpdater.getRowsForProperties( labelInfo );
			$res.append(
				$( '<p>' ).append( '...now comparing to current content' )
			);
			SortedPropertiesUpdater.compareWithCurrent(
				goodRows,
				inputs.autoUpdate
			);
		}
	);
};

});

$( document ).ready( () => {
	mw.loader.using(
		[ 'mediawiki.util' ],
		function () {
			mw.util.addPortletLink(
				'p-tb',
				'/wiki/Special:BlankPage/SortedPropertiesUpdater',
				'View updates for SortedProperties'
			);
		}
	);
	if ( mw.config.get( 'wgNamespaceNumber' ) === -1 ) {
		const page = mw.config.get( 'wgCanonicalSpecialPageName' );
		if ( page === 'Blankpage' ) {
			const page2 = mw.config.get( 'wgTitle' ).split( '/' );
			if ( page2[1] && page2[1] === 'SortedPropertiesUpdater' ) {
				window.SortedPropertiesUpdater.init();
			}
		}
	}
});

// </nowiki>