User:Jon Harald Søby/copySenses.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.
/**
 * Script to copy senses from one lexeme to another.
 * 
 * If the lexeme that is being copied from is in the same language as the
 * lexeme you are on, the senses will be added as synonyms of each other.
 * If the lexeme is in a different language, the senses will be added as
 * translations of each other.
 * 
 * You can copy all senses from one lexeme to another by using just the
 * lexeme ID (e.g. L12345), or you can copy just a single sense by using
 * the sense ID (e.g. L12345-S1).
 * 
 * If you don't want to add translation statements when using this script,
 * add the following line before you include the script:
 *    mw.config.set( 'userjs-copysenses-excludetranslations', true );
 * 
 * @version 1.6.1 (2023-04-02)
 * @author Jon Harald Søby
 */
/* jshint esversion: 8 */
mw.loader.using( [ 'mediawiki.api', 'mediawiki.util', 'oojs', 'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows' ] ).then( function() {
	'use strict';

	if ( mw.config.get( 'wgNamespaceNumber' ) !== 146 ) return;
	if ( window.copysenses_excludetranslations ) {
		mw.config.set( 'userjs-copysenses-excludetranslations', true );
		mw.log.warn(
			'copySenses.js: The method `window.copysenses_excludetranslations` is deprecated, ' +
			'please use `mw.config.set( \'userjs-copysenses-excludetranslations\', true );` instead.'
		);
	}
	
	const api = new mw.Api(),
		  thisscript = 'User:Jon Harald Søby/copySenses.js',
		  addtranslations = !mw.config.get( 'userjs-copysenses-excludetranslations' ),
		  version = '1.6.0',
		  editGroupHash = Math.floor( Math.random() * Math.pow( 2, 48 ) ).toString( 16 ),
		  summarydetails = ' ([[:toolforge:editgroups/b/copysenses/' + editGroupHash + '|details]])';
	console.log( 'copySenses.js v' + version );
		  
	let propconf = {
	  	safe: [
		  		'P5137', // item for this sense
		  		'P9970', // predicate for
		  		'P6271', // demonym for
		  		'P18' // image
	  		],
	  	safe_samelang: [
	  			'P5974' // antonym
  			]
	};
	
	let cachedSynonyms = {
		'lexemes': new Set()
	};

	let cachedBacklinks = {};
	
	/**
	 * Get the next sense ID from the API.
	 * 
	 * @returns {string}
	 */
	async function lookupNextSenseId() {
		let apires = await api.get( {
			action: 'query',
			format: 'json',
			prop: 'revisions',
			titles: mw.config.get( 'wgPageName' ),
			formatversion: 2,
			rvprop: 'content',
			rvslots: 'main'
		} ).then( function( data ) {
			return JSON.parse( data.query.pages[ 0 ].revisions[ 0 ].slots.main.content );
		} );
		return apires.nextSenseId;
	}

	/**
	 * Get data from another lexeme
	 * 
	 * @param {string} lexid - Lexeme ID of another lexeme
	 * @returns {Promise}
	 */
	async function getOtherLexeme( lexid, singlesense, nextSenseId ) {
		if ( singlesense ) {
			lexid = lexid.split( '-' )[ 0 ];
		}
		cachedSynonyms.lexemes.add( lexid );
		let apires = await api.get( {
			action: 'wbgetentities',
			format: 'json',
			formatversion: 2,
			ids: lexid
		} ).then( function( data ) {
			if (
				data.hasOwnProperty( 'entities' ) &&
				data.entities.hasOwnProperty( lexid ) &&
				!data.entities[ lexid ].hasOwnProperty( 'missing' ) &&
				data.entities[ lexid ].senses.length
			) {
				let senselist = data.entities[ lexid ].senses,
					senseid = nextSenseId - 1;
				for ( let sense in senselist ) {
					if ( singlesense && senselist[ sense ].id !== singlesense ) {
						continue;
					}
					++senseid;
					cachedSynonyms[ 'S' + senseid ] = [ [ lexid, senselist[ sense ].id ] ];
					for ( let claim in senselist[ sense ].claims ) {
						if ( claim === 'P5972' || claim === 'P5973' ) { // translation || synonym
							for ( let statement of senselist[ sense ].claims[ claim ] ) {
								let transsense = statement.mainsnak.datavalue.value.id,
									translex = transsense.split( '-' )[ 0 ];
								cachedSynonyms.lexemes.add( translex );
								cachedSynonyms[ 'S' + senseid ].push( [ translex, transsense ] );
							}
						}
					}
				}
				return data.entities[ lexid ];
			} else {
				return false;
			}
		});
		return apires;
	}
	
	/**
	 * Add senses from another lexeme to the current lexeme.
	 * 
	 * @param {string} sourcelexeme - a lexeme ID
	 * @param {string} thislang - 
	 * @returns {Object[]}
	 */
	async function prepareSensesForCurrentLexeme( otherlexeme, thislang, singlesense, nextSenseId ) {
		let otherLex = JSON.parse( JSON.stringify( otherlexeme ) ),
			otherlang = otherLex.language,
			samelang = thislang === otherlang,
			senses = otherLex.senses,
			langcheck = await checkTranslationLanguages( cachedSynonyms, thislang ),
			newsenses = [],
			senseid = nextSenseId - 1;
		for ( let sense of senses ) {
			if ( singlesense && singlesense !== sense.id ) {
				continue;
			}
			++senseid;
			cachedBacklinks[ 'S' + senseid ] = [];
			let newsense = {
				add: '',
				glosses: sense.glosses,
				claims: {}
			};
			for ( let claim in sense.claims ) {
				if ( !newsense.claims.hasOwnProperty( claim ) ) {
					newsense.claims[ claim ] = [];
				}
				for ( let statement of sense.claims[ claim ] ) {
					if ( propconf.safe.includes( claim ) ) {
						newsense.claims[ claim ].push( statement );
					} else if ( samelang && propconf.safe_samelang.includes( claim ) ) {
						newsense.claims[ claim ].push( statement );
					}
				}
			}
			for ( let claim in newsense.claims ) {
				for ( let statement of newsense.claims[ claim ] ) {
					delete statement.mainsnak.hash;
					delete statement.id;
				}
			}
			for ( let synonym of cachedSynonyms[ 'S' + senseid ] ) {
				let samelangcheck = langcheck.samelang.includes( synonym[ 0 ] ),
					synprop = samelangcheck ? 'P5973' : 'P5972'; // synonym : translation
				if ( !newsense.claims.hasOwnProperty( synprop ) ) {
					newsense.claims[ synprop ] = [];
				}
				if ( samelangcheck || addtranslations ) {
					newsense.claims[ synprop ].push( {
						mainsnak: {
							snaktype: 'value',
							property: synprop,
							datavalue: {
								value: {
									'entity-type': 'sense',
									id: synonym[ 1 ]
								},
								type: 'wikibase-entityid'
							},
							datatype: 'wikibase-sense'
						},
						type: 'statement',
						rank: 'normal'
					} );
					cachedBacklinks[ 'S' + senseid ].push( { prop: synprop, id: synonym[ 1 ] } );
				}
			}
			newsenses.push( newsense );
		}
		return newsenses;
	}
	
	/**
	 * Add appropriate statements linking to the current lexeme from the source
	 * lexeme or any other relevant lexemes (such as other synonyms, for
	 * instance).
	 * 
	 * @param {string} otherlexeme - the lexeme ID that should be edited
	 * @param {string} thislang - QID of this lexeme's language
	 * @returns {Object[]}
	 */
	async function addBacklinksToOtherLexeme( otherlexeme, thislang, thisid, singlesense, nextSenseId ) {
		let otherLex = JSON.parse( JSON.stringify( otherlexeme ) ),
			otherlang = otherLex.language,
			prop = ( thislang === otherlang ) ? 'P5973' : 'P5972',
			senseid = nextSenseId - 1;
		if ( addtranslations || thislang === otherlang ) {
			for ( let sense in otherLex.senses ) {
				if ( singlesense && singlesense !== otherLex.senses[ sense ].id ) {
					continue;
				}
				++senseid;
				let snak = {
					add: '',
					mainsnak: {
						datatype: 'wikibase-sense',
						datavalue: {
							type: 'wikibase-entityid',
							value: {
								'entity-type': 'sense',
								id: thisid + '-S' + senseid
							}
						},
						property: prop,
						snaktype: 'value'
					},
					rank: 'normal',
					type: 'statement'
				};
				if ( otherLex.senses[ sense ].claims.hasOwnProperty( prop ) ) {
					otherLex.senses[ sense ].claims[ prop ].push( snak );
				} else {
					otherLex.senses[ sense ].claims[ prop ] = [ snak ];
				}
			}
		}
		return otherLex.senses;
	}
	
	/**
	 * Check the language of the senses linked to in translation (P5972)
	 * statements.
	 * 
	 * @param {Object} cache
	 * @param {string} thislang - the QID for the lanugage of the current lexeme
	 * @returns {Object}
	 */
	async function checkTranslationLanguages( cache, thislang ) {
		let samelang = [],
			difflang = [];
		let apires = await api.get( {
			action: 'wbgetentities',
			format: 'json',
			ids: [...cachedSynonyms.lexemes].join( '|' ),
			formatversion: 2
		} );
		if ( apires.entities ) {
			for ( let entity in apires.entities ) {
				if ( apires.entities[ entity ].language === thislang ) {
					samelang.push( entity );
				} else {
					difflang.push( entity );
				}
			}
		}
		return {
			samelang: samelang,
			difflang: difflang
		};
	}
	
	/**
	 * Save senses to a lexeme
	 * 
	 * @param {string} lexid - the lexeme to be edited
	 * @param {string} summary - summary to be used for the edit
	 * @param {Object} senses
	 * @returns {Promise}
	 */
	async function saveSenses( lexid, summary, senses ) {
		let apireq = await api.postWithEditToken( {
			action: 'wbeditentity',
			format: 'json',
			formatversion: 2,
			id: lexid,
			data: JSON.stringify( { senses: senses } ),
			summary: summary + summarydetails
		} );
		return apireq;
	}
	
	/**
	 * Save a single external sense claim with a link back to this lexeme.
	 * 
	 * @param {string} senseid - the sense ID for the sense on this lexeme
	 * @param {Object} claim
	 * @returns {Promise}
	 */
	async function saveSingleClaim( senseid, claim ) {
		let apireq = api.postWithEditToken( {
			action: 'wbcreateclaim',
			format: 'json',
			entity: claim.id,
			snaktype: 'value',
			property: claim.prop,
			value: JSON.stringify( { 'entity-type': 'sense', id: senseid } ),
			summary: '[[' + thisscript + '|copySenses.js]]' + summarydetails
		});
		return apireq;
	}
	
	/**
	 * Turn the API response with the other lexeme into
	 * human-friendly HTML.
	 * 
	 * @param {Object} otherlexeme
	 * @returns {jQuery}
	 */
	function htmlizeSenses( otherlexeme, singlesense ) {
		let thisdiv = $( '<div />' );
		if ( !otherlexeme ) {
			thisdiv.html( '<i>Not a valid lexeme, or no senses in lexeme.</i>' );
			return thisdiv;
		}
		let senses = otherlexeme.senses,
			senseid = 0;
		for ( let sense of senses ) {
			++senseid;
			if ( singlesense && singlesense.split( '-' )[ 1 ] !== 'S' + senseid ) continue;
			let sensediv = $( '<div />' ),
				sensestrong = $( '<strong>S' + senseid + '</strong>' ),
				senseul = $( '<ul>' );
			for ( let gloss in sense.glosses ) {
				senseul.append( $( '<li><code>' + gloss + '</code>: ' + sense.glosses[ gloss ].value + '</li>' ) );
			}
			sensediv.append( sensestrong ).append( senseul );
			thisdiv.append( sensediv );
		}
		return thisdiv;
	}
	
	mw.hook( 'wikibase.entityPage.entityLoaded' ).add( function( e ) {
		const language = e.language;

		mw.util.addCSS(
			'.wikibase-lexeme-senses { clear: both; } ' +
			'.copySenseEntry { width: 20em; } ' +
			'#copysensediv { border: 1px solid rgba(128,128,128,0.5); padding: 1em; background-color: rgba(255,255,255,0.3); border-radius: 1em; box-shadow: 0 0 50px #ccc; }'
		);

		let copySensesTextinput = new OO.ui.TextInputWidget( {
					accessKey: ','
				} ).on( 'enter', function() {
					copySensesButton.$element.click();
				} ),
			copySensesButton = new OO.ui.ButtonWidget( {
					label: 'Copy senses',
					flags: [ 'primary', 'progressive' ]
				} ),
			copySensesField = new OO.ui.ActionFieldLayout( copySensesTextinput, copySensesButton, {
					classes: [ 'copySenseEntry' ]
				} ),
			confirmButton = new OO.ui.ButtonWidget( {
					label: 'Confirm',
					flags: [ 'primary', 'progressive' ],
					accessKey: 's'
				}),
			cancelButton = new OO.ui.ButtonWidget( {
					label: 'Cancel',
					flags: [ 'destructive' ],
					framed: false
				}),
			refreshButton = new OO.ui.ButtonWidget( {
					label: 'Refresh the page',
					flags: [ 'primary', 'progressive' ],
					accessKey: 's'
				}),
			buttonLayout = new OO.ui.HorizontalLayout( {
					items: [ confirmButton, cancelButton ]
				});

		copySensesButton.on( 'click', async function() {
			let sourcelexeme = copySensesField.getField().value.toUpperCase().trim(),
				nextSenseId = await lookupNextSenseId(),
				singlesense = /^L\d+-S\d+$/.test( sourcelexeme ) ? sourcelexeme : false,
				otherlexeme = await getOtherLexeme( sourcelexeme, singlesense, nextSenseId ),
				addsenseshere = await prepareSensesForCurrentLexeme( otherlexeme, language, singlesense, nextSenseId ),
				addbacklinksthere = await addBacklinksToOtherLexeme( otherlexeme, language, e.id, singlesense, nextSenseId );
			copySensesField.$element.hide();
			$( '#copysensediv' )
				.append( htmlizeSenses( otherlexeme, singlesense ) )
				.append( buttonLayout.$element.css( 'margin-top', '1em' ) )
				.append( $( '<ul id="resultsdiv" />' ) );

			confirmButton.on( 'click', async function() {
				mw.hook( 'userjs.copysenses' ).fire();
				confirmButton.setDisabled( true );
				let spinner1 = $.createSpinner( 'spinner1' ),
					spinner2 = $.createSpinner( 'spinner2' );
				let action1 = $( '<li> Saving senses to this lexeme</li>' ).prepend( spinner1 ),
					action2 = $( '<li> Saving links back to this lexeme to ' + sourcelexeme + '</li>' ).prepend( spinner2 );
				const editGroupUrl = 'https://editgroups.toolforge.org/b/copysenses/' + editGroupHash + '/';
				const $editGroupLink = $( '<div>' )
					.html( 'Change your mind? You can revert these changes with <a href="' + editGroupUrl + '">Edit Groups</a>.' );
				$( '#resultsdiv' ).append( action1 );
				$( '#resultsdiv' ).append( action2 );
				let savethis = await saveSenses( e.id, '[[' + thisscript + '|Copy senses]] from [[Lexeme:' + sourcelexeme.split( '-' )[ 0 ] + '|' + sourcelexeme + ']]', addsenseshere );
				if ( savethis.hasOwnProperty( 'success' ) ) {
					$( '#mw-spinner-spinner1' ).replaceWith( '✔ ' );
					let savethat = await saveSenses( sourcelexeme.split( '-' )[ 0 ], 'This lexeme\'s [[' + thisscript + '|senses copied]] to [[Lexeme:' + e.id + '|' + e.id + ']]; adding backlinks', addbacklinksthere );
					if ( savethat.hasOwnProperty( 'success' ) ) {
						$( '#mw-spinner-spinner2' ).replaceWith( '✔' );
					} else {
						$( '#mw-spinner-spinner2' ).replaceWith( '❌ ' );
						action2.append( $( ' <code class="error">' + savethat.error.code + '</code>' ) );
					}
				} else {
					$( '#mw-spinner-spinner1' ).replaceWith( '❌ ' );
					action1.append( $( ' <code class="error">' + savethis.error.code + '</code>' ) );
				}
				for ( let sense in cachedBacklinks ) {
					let senseid = e.id + '-' + sense;
					for ( let claim of cachedBacklinks[ sense ] ) {
						if ( claim.id.split( '-' )[ 0 ] === sourcelexeme.split( '-' )[ 0 ] ) {
							continue;
						}
						await new Promise( r => setTimeout( r, 250 ) );
						let partaction = $( '<li> Saving backlink to ' + sense + ' to ' + claim.id + '</li>' ).prepend( $.createSpinner( claim.id ) );
						$( '#resultsdiv' ).append( partaction );
						saveSingleClaim( senseid, claim ).then( function( data ) {
							if ( data.hasOwnProperty( 'success' ) ) {
								$( '#mw-spinner-' + claim.id ).replaceWith( '✔ ' );
							} else {
								$( '#mw-spinner-' + claim.id ).replaceWith( '❌ ' );
								partaction.append( $( '<code class="error">' + data.error.code + '</code>' ) );
							}
						}).catch( function( err ) {
							$( '#mw-spinner-' + claim.id ).replaceWith( '❌ ' );
							partaction.append( $( '<code class="error">' + err + '</code>' ) );
						});
					}
				}

				refreshButton.on( 'click', function() {
					location.reload();
				});
				$( '#resultsdiv' ).after( refreshButton.$element );
				refreshButton.$element.after( $editGroupLink );
			});

			cancelButton.on( 'click', function() {
				location.reload();
			});
		});

		$( '.wikibase-lexeme-senses' ).append( $( '<div id="copysensediv" />') );
		$( '#copysensediv' ).html( copySensesField.$element );
	});
});