MediaWiki:Gadget-ImportPagelist.js

Note: After saving, changes may not occur immediately. Click here to learn how to bypass your browser's cache.
  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (Cmd-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (Cmd-Shift-R on a Mac)
  • Internet Explorer: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Clear the cache in Tools → Preferences

For details and instructions about other browsers, see Wikipedia:Bypass your cache.

/**
 * Import pagelist gadget
 *
 * Adds a link to import a pagelist from a given IA identifier
 *
 * Uses the pagelister.toolforge.org tool
 *
 * Changelog:
 *   * 2020-10-24:  Initial version
 *   * 2020-11-27:  Update cover image from title page, if there is one
 *   * 2021-01-06:  Handle Hathi links
 */

/* eslint-disable
		camelcase
*/

'use strict';

// IIFE used when including as a user script (to allow debug or config)
// Default gadget use will get an IIFE wrapper as well
( function () {

	mw.messages.set( {
		'ipl-to-be-proofread-status': 'C',
		'ipl-cancel': 'Cancel',
		'ipl-import': 'Import',
		'ipl-dialog-title': 'Import pagelist',
		'ipl-source-ia': 'Internet Archive',
		'ipl-source-hathi': 'Hathi Trust',
		'ipl-source': 'Source',
		'ipl-id': 'ID',
		'ipl-offset': 'Offset',
		'ipl-offset-help': 'Offset between the source and the file. If the file are identical, this is 0. If a cover page has been removed, it is 1',

		'ipl-first-page': 'First page',
		'ipl-first-page-title': 'The first page (as an index in the file)',
		'ipl-repeat': 'Repeat',
		'ipl-repeat-title': 'How many times to repeat the whole pattern',
		'ipl-non-numeric-pages': 'Non-numeric pages',
		'ipl-non-numeric-pages-title': 'Enter a list of non numeric pages, for example "Img,-,Img,-',
		'ipl-non-numeric-default': 'Img,-',
		'ipl-non-numeric-not-found-error': 'The line above the cursor does not appear to contain a page number that can be used. E.g. 100=152.',

		'ipl-error-msg': 'An error occurred:',
		'ipl-bad-response-msg': 'Bad response from pagelist server',

		'ipl-link-label': 'Import pagelist',
		'ipl-link-title': 'Import pagelist from an external source like the Internet Archive or HathiTrust',
		'ipl-nonnum-label': 'Add non-numeric pages',
		'ipl-nonnum-title': 'Add non-numeric page entries at the current cursor position.',
		'ipl-link-page-game': 'Open in WS Page Game',
		'ipl-link-page-game-title': 'Open in the external WS Page Game tool',

		'ipl-set-pagelist-summary': 'Importing pagelist using the [[MediaWiki:Gadget-ImportPagelist|Import Pagelist gadget]] (source: $1:$2)',
		'ipl-ctrl-enter-to-confirm': '<kbd>Ctrl</kbd>+<kbd>Enter</kbd> to confirm.'
	} );

	var gadgetName = 'import_pagelist';

	var IPL = {
		enabled: true,
		configured: false,
		host: 'https://pagelister.toolforge.org',
		pageGameUrl: 'https://ws-page-game.toolforge.org',
		sharedRepoUrl: 'https://commons.wikimedia.org/w/api.php',
		setIndexStatus: false
	};

	function getInternetArchiveIdFromUrl( url ) {
		var rx = /(details|manage|download)\/([^/]*)/,
			match = rx.exec( url.pathname );

		if ( match ) {
			return match[ 2 ];
		}

		return null;
	}

	function getHathiIdFromUrl( url ) {
		if ( url.hostname.match( /hathitrust.org$/ ) ) {
			// null if not found
			return url.searchParams.get( 'id' );
		} else if ( url.hostname.match( /hdl.handle.net$/ ) ) {
			return url.pathname.split( '/' )[ 2 ];
		}
		return null;
	}

	// Find IA links in a page's content
	function findUsefulLinks( data ) {

		for ( var page in data.query.pages ) {
			var extlinks = data.query.pages[ page ].extlinks;

			if ( extlinks ) {
				for ( var eli = 0; eli < extlinks.length; eli++ ) {
					var el = extlinks[ eli ].url;

					if ( el.startsWith( '//' ) ) {
						el = window.location.protocol + l;
					}
					var url = new URL( el );

					var id;
					if ( url.hostname.match( /archive.org$/ ) ) {
						id = getInternetArchiveIdFromUrl( url );
						if ( id !== null ) {
							return { source: 'ia', id: id };
						}
					} else if ( url.hostname.match( /hathitrust.org$/ ) ||
						( url.hostname.match( /hdl.handle.net$/ ) ) ) {
						id = getHathiIdFromUrl( url );
						if ( id !== null ) {
							return { source: 'ht', id: id };
						}
					}
				}
			}

			var iwlinks = data.query.pages[ page ].iwlinks;

			if ( iwlinks ) {
				for ( var i = 0; i < iwlinks.length; i++ ) {
					var l = iwlinks[ i ];
					if ( l.prefix === 'iarchive' ) {
						return { source: 'ia', id: l.title };
					}
				}
			}
		}

		return null;
	}

	function getIsFileLocal( fn, callback ) {
		// figure out if the file is local or shared
		var params = {
			action: 'query',
			format: 'json',
			formatversion: '2',
			prop: 'imageinfo',
			iiprop: '',
			titles: fn
		};

		new mw.Api().get( params ).done( function ( data ) {
			callback( data.query.pages[ 0 ].imagerepository === 'local' );
		} );
	}

	function findLikelyIds( callback ) {

		var fn = 'File:' + mw.config.get( 'wgTitle' );

		mw.loader.using( [ 'mediawiki.ForeignApi', 'mediawiki.api' ], function () {

			getIsFileLocal( fn, function ( local ) {

				var api = local ?
					new mw.Api() :
					new mw.ForeignApi( IPL.sharedRepoUrl );

				// Get all external links from the Commons file page
				api.get( {
					action: 'query',
					format: 'json',
					formatversion: 2,
					prop: 'extlinks|iwlinks',
					redirects: true,
					titles: fn,
					ellimit: 100,
					iwlimit: 100
				} ).done( function ( data ) {
					var link = findUsefulLinks( data );

					if ( link !== null ) {
						callback( link );
					}

				} ).fail( function ( data ) {
					console.log( 'Commons GET Failed:', data );
				} );
			} );
		} );
	}

	function setPagelistValue( plv ) {
		OO.ui.infuse( $( '#wpprpindex-Pages' ).parent() ).setValue( plv );
	}

	function setCoverValue( plv ) {
		// Set the cover image if there is one
		var match = /(\d+)=["']?[Tt]itle["']\s/.exec( plv );

		if ( match ) {
			$( '#wpprpindex-Image' ).val( match[ 1 ] );
		}
	}

	function setIndexStatus() {
		if ( IPL.setIndexStatus ) {
			OO.ui.infuse( $( '#wpprpindex-Progress' ).parent() )
				.setValue( mw.msg( 'ipl-to-be-proofread-status' ) );
		}
	}

	function setPagelistFieldEnabled( enabled ) {
		OO.ui.infuse( $( '#wpprpindex-Pages' ).parent() ).setDisabled( !enabled );
	}

	function setSummary( source, id ) {
		$( '#wpSummary' ).val( mw.msg( 'ipl-set-pagelist-summary', source, id ) );
	}

	function installDialog() {

		var ParamDialog = function ( config ) {
			config = config || {};
			config.escapable = true;
			ParamDialog.super.call( this, config );
		};
		OO.inheritClass( ParamDialog, OO.ui.ProcessDialog );

		// Specify a name for .addWindows()
		ParamDialog.static.name = 'importPageListDialog';
		ParamDialog.static.title = mw.msg( 'ipl-dialog-title' );
		// Specify the static configurations: title and action set
		ParamDialog.static.actions = [ {
			flags: 'primary',
			label: mw.msg( 'ipl-import' ),
			action: 'open'
		},
		{
			flags: 'safe',
			label: mw.msg( 'ipl-cancel' )
		}
		];

		// Customize the initialize() function to add content and layouts:
		ParamDialog.prototype.initialize = function () {
			ParamDialog.super.prototype.initialize.call( this );
			this.panel = new OO.ui.PanelLayout( {
				padded: true,
				expanded: false
			} );
			this.content = new OO.ui.FieldsetLayout();

			this.inputs = {};
			this.fields = {};

			this.inputs.source = new OO.ui.DropdownInputWidget( {
				options: [
					{ data: 'ia', label: mw.msg( 'ipl-source-ia' ) },
					{ data: 'ht', label: mw.msg( 'ipl-source-hathi' ) }
				] } );

			// this.inputs['source'].selectItem( option1 );

			this.fields.source = new OO.ui.FieldLayout( this.inputs.source, {
				label: mw.msg( 'ipl-source' ),
				align: 'right'
			} );
			this.content.addItems( [ this.fields.source ] );

			this.inputs.id = new OO.ui.TextInputWidget();

			this.fields.id = new OO.ui.FieldLayout( this.inputs.id, {
				label: mw.msg( 'ipl-id' ),
				align: 'right'
			} );
			this.content.addItems( [ this.fields.id ] );

			this.inputs.offset = new OO.ui.NumberInputWidget( {
				value: 0,
				step: 1
			} );

			this.fields.offset = new OO.ui.FieldLayout( this.inputs.offset, {
				label: mw.msg( 'ipl-offset' ),
				help: mw.msg( 'ipl-offset-help' ),
				align: 'right'
			} );
			this.content.addItems( [ this.fields.offset ] );

			this.panel.$element.append( this.content.$element );

			var toolLink = '<a href="' + IPL.host + '">' + IPL.host.replace( /https?:\/\//, '' ) + '</a>';

			this.panel.$element.append( $( '<hr/><p style="font-size:90%;">Note: this tool will access ' + toolLink + '.</p>' ) );

			// disable help tabbing, which gets in the way a LOT
			this.content.$element.find( '.oo-ui-fieldLayout-help a' )
				.attr( 'tabindex', '-1' );

			this.$body.append( this.panel.$element );
		};

		ParamDialog.prototype.prefill_id = function ( link ) {
			this.inputs.id.setValue( link.id );
			this.inputs.source.setValue( link.source );
		};

		// Use getSetupProcess() to set up the window with data passed to it at the time
		// of opening (e.g., url: 'http://www.mediawiki.org', in this example).
		ParamDialog.prototype.getSetupProcess = function ( data ) {
			var self = this;
			data = data || {};
			return ParamDialog.super.prototype.getSetupProcess.call( this, data )
				.next( function () {
					// Fire off a request to see if we can find any useful IDs in the commons page
					findLikelyIds( function ( data ) {
						self.prefill_id( data );
					} );
				}, this );
		};

		function report_error( msg ) {
			alert( mw.msg( 'ipl-error-msg' ) + '\n\n' + msg );
		}

		// Specify processes to handle the actions.
		ParamDialog.prototype.getActionProcess = function ( action ) {
			var dialog = this;
			if ( action === 'open' ) {
				// Create a new process to handle the action
				return new OO.ui.Process( function () {
					setPagelistFieldEnabled( false );

					var source = dialog.inputs.source.getValue();
					var extId = dialog.inputs.id.getValue();

					fetch( IPL.host + '/pagelist/v1/list?' + new URLSearchParams( {
						source: source,
						id: extId,
						offset: dialog.inputs.offset.getNumericValue()
					} ) )
						.then( function ( response ) { return response.json(); } )
						.then( function ( data ) {
							if ( data ) {

								if ( data.errors !== undefined ) {
									var errors = data.errors.map( function ( e ) {
										return e.msg;
									} ).join( '\n' );
									report_error( errors );
								} else if ( data.pagelist !== undefined ) {
									var plv = data.pagelist;
									setPagelistValue( plv );
									setCoverValue( plv );
									setIndexStatus();
									setSummary( source, extId );
								} else {
									report_error( mw.msg( 'ipl-bad-response-msg' ) );
								}
							}
						} )
						.finally( function () { setPagelistFieldEnabled( true ); } );

					dialog.close( {
						action: action
					} );
				}, this );
			}
			// Fallback to parent handler
			return ParamDialog.super.prototype.getActionProcess.call( this, action );
		};

		ParamDialog.prototype.getTeardownProcess = function ( data ) {
			return ParamDialog.super.prototype.getTeardownProcess.call( this, data )
				.first( function () {
					// Perform any cleanup as needed
				}, this );
		};

		// Create and append a window manager.
		var windowManager = new OO.ui.WindowManager();
		$( 'body' ).append( windowManager.$element );

		// Create a new process dialog window.
		var paramDlg = new ParamDialog();

		// Add the window to window manager using the addWindows() method.
		windowManager.addWindows( [ paramDlg ] );

		// Open the window!
		windowManager.openWindow( paramDlg );

		// focus the input in just a moment
		setTimeout( function () {
			paramDlg.$body.find( 'input' )[ 0 ].focus();
		}, 300 );
	}

	// User clicked - install the dialog
	function activate( evt ) {
		mw.loader.using( [ 'mediawiki.util', 'oojs-ui-core', 'oojs-ui-widgets' ],
			installDialog );
		evt.preventDefault();
	}

	function install_non_numeric_dialog( setupData ) {

		var ParamDialog = function ( config ) {
			ParamDialog.super.call( this, config );
		};
		OO.inheritClass( ParamDialog, OO.ui.ProcessDialog );

		// Specify a name for .addWindows()
		ParamDialog.static.name = 'addNonNumericDialog';
		ParamDialog.static.title = IPL.nonnum_dialog_title;
		ParamDialog.static.escapable = true;
		// Specify the static configurations: title and action set
		ParamDialog.static.actions = [ {
			flags: 'primary',
			label: 'OK',
			action: 'insert'
		},
		{
			flags: 'safe',
			label: mw.msg( 'ipl-cancel' )
		}
		];

		// Customize the initialize() function to add content and layouts:
		ParamDialog.prototype.initialize = function () {
			ParamDialog.super.prototype.initialize.call( this );
			this.panel = new OO.ui.PanelLayout( {
				padded: true,
				expanded: false,
				escapable: true
			} );
			this.content = new OO.ui.FieldsetLayout();

			this.inputs = {};
			this.fields = {};

			this.inputs.firstIndex = new OO.ui.NumberInputWidget( {
				value: 0,
				min: 1,
				step: 1
			} );

			this.fields.firstIndex = new OO.ui.FieldLayout(
				this.inputs.firstIndex,
				{
					label: mw.msg( 'ipl-first-page' ),
					help: mw.msg( 'ipl-first-page-title' ),
					align: 'right'
				} );

			this.inputs.pages = new OO.ui.TextInputWidget( {
				value: 'Img,-'
			} );
			this.fields.pages = new OO.ui.FieldLayout( this.inputs.pages, {
				label: mw.msg( 'ipl-non-numeric-pages' ),
				help: mw.msg( 'ipl-non-numeric-pages-title' ),
				align: 'right'
			} );

			this.inputs.repeatCount = new OO.ui.NumberInputWidget( {
				value: 1,
				min: 0,
				step: 1
			} );

			this.fields.repeatCount = new OO.ui.FieldLayout(
				this.inputs.repeatCount,
				{
					label: mw.msg( 'ipl-repeat' ),
					help: mw.msg( 'ipl-repeat-title' ),
					align: 'right'
				} );

			this.content.addItems( [ this.fields.firstIndex, this.fields.pages,
				this.fields.repeatCount ] );

			this.panel.$element.append( this.content.$element );

			this.panel.$element.append(
				$( '<hr>' ),
				$( '<p>' )
					.css( 'font-size', '90%' )
					.append( mw.msg( 'ipl-ctrl-enter-to-confirm' ) )
			);

			// disable help tabbing, which gets in the way a LOT
			this.content.$element.find( '.oo-ui-fieldLayout-help a' )
				.attr( 'tabindex', '-1' );

			this.$body.append( this.panel.$element );
		};

		// Use getSetupProcess() to set up the window with data passed to it at the time
		// of opening (e.g., url: 'http://www.mediawiki.org', in this example).
		ParamDialog.prototype.getSetupProcess = function ( data ) {
			var dialog = this;
			data = data || {};
			return ParamDialog.super.prototype.getSetupProcess.call( this, data )
				.next( function () {
					// pre-fill a page number
					dialog.inputs.firstIndex.setValue( data.firstIndex );
					dialog.saveCallback = data.saveCallback;
				}, this );
		};

		// Specify processes to handle the actions.
		ParamDialog.prototype.getActionProcess = function ( action ) {
			var dialog = this;
			if ( action === 'insert' ) {
				// Create a new process to handle the action
				return new OO.ui.Process( function () {

					var pages = dialog.inputs.pages.getValue().split( ',' );
					pages = pages.map( function ( s ) {
						s = s.trim();

						if ( [ 'img', 'plate', 'cover' ].indexOf( s.toLowerCase() ) !== -1 ) {
							s = s[ 0 ].toUpperCase() + s.substring( 1 ).toLowerCase();
						}

						if ( s === '-' ) {
							s = '–';
						}

						return s;
					} );

					var repeated = [];

					var rpt = dialog.inputs.repeatCount.getNumericValue();
					if ( rpt > 0 ) {
						for ( var i = 0; i < rpt; ++i ) {
							repeated = repeated.concat( pages );
						}
					}

					var retData = {
						firstIndex: dialog.inputs.firstIndex.getNumericValue(),
						pages: repeated
					};

					dialog.saveCallback( retData );

					dialog.close( {
						action: action
					} );
				}, this );
			}
			// Fallback to parent handler
			return ParamDialog.super.prototype.getActionProcess.call( this, action );
		};

		// Create and append a window manager.
		var windowManager = new OO.ui.WindowManager();
		$( 'body' ).append( windowManager.$element );

		// Create a new process dialog window.
		var paramDlg = new ParamDialog();

		// Add the window to window manager using the addWindows() method.
		windowManager.addWindows( [ paramDlg ] );

		// Open the window!
		windowManager.openWindow( paramDlg, setupData );

		// focus the input in just a moment
		setTimeout( function () {
			paramDlg.$body.find( 'input' )[ 0 ].focus();
		}, 500 );
	}

	function getPreviousLine( val, pos ) {
		var prevLineEnd = val.lastIndexOf( '\n', pos );
		var prevLineStart = val.lastIndexOf( '\n', prevLineEnd - 1 ) + 1;

		var prevLine = val.substring( prevLineStart, prevLineEnd );

		return prevLine;
	}

	function getPageListParts( line ) {
		var parts = line.split( '=' );

		if ( parts.length === 2 ) {
			// strip whitespace and quotes
			return parts.map( function ( s ) {
				return s.replace( /\s*['"].*['"]?\s*/g, '' );
			} );
		}

		return null;
	}

	function insertImgDash( evt ) {

		mw.loader.using(
			[ 'mediawiki.util', 'oojs-ui-core', 'oojs-ui-widgets' ],
			function () {
				var $tb = $( '#wpprpindex-Pages' );

				var pos = $tb.getCursorPosition();
				var val = $tb.val();

				// skip up to the last line
				while ( pos > 0 && val[ pos - 1 ] === '\n' ) {
					pos--;
				}

				var prevLine = getPreviousLine( val, pos );

				var lineParts = getPageListParts( prevLine );

				var offset;
				if ( lineParts ) {
					offset = parseInt( lineParts[ 1 ] ) - parseInt( lineParts[ 0 ] );
				}

				if ( offset === undefined || Number.isNaN( offset ) ) {
					mw.notify( mw.msg( 'ipl-non-numeric-not-found-error' ),
						{ autoHide: false, type: 'error' }
					);
				} else {
					// function to run when the dialog returns
					var update = function ( params ) {
						var pageIndex = params.firstIndex;

						var newText = '';

						var firstVal;
						var allSame = true;

						params.pages.forEach( function ( page ) {
							if ( !firstVal ) {
								firstVal = page;
							}

							if ( allSame && page !== firstVal ) {
								allSame = false;
							}
						} );

						if ( allSame && params.pages.length > 1 ) {
							newText += '\n' + pageIndex + 'to' + ( pageIndex + params.pages.length - 1 ) +
								'="' + firstVal + '"';
							pageIndex += params.pages.length;
						} else {
							params.pages.forEach( function ( page ) {
								newText += '\n' + pageIndex + '="' + page + '"';
								pageIndex++;
							} );
						}

						newText += '\n' + pageIndex + '=' + ( params.firstIndex + offset );
						newText += '\n\n';
						val = val.substring( 0, pos ) + newText + val.substring( pos ).trimStart();
						$tb.val( val );

						$tb.setCursorPosition( pos + newText.length - 1 );

						setTimeout( function () {
							$tb.trigger( 'focus' );
						}, 500 );
					};

					install_non_numeric_dialog( {
						saveCallback: update,
						firstIndex: parseInt( lineParts[ 0 ] ) + 1
					} );
				}
			}
		);

		evt.preventDefault();
	}

	function goToPageGameUrl() {
		var url = IPL.pageGameUrl +
			'?index=' + mw.config.get( 'wgTitle' ) +
			'&wikisource=' + mw.config.get( 'wgWikiID' );

		return url;
	}

	function makeButton( label, title, options ) {
		var $link = $( '<a>' )
			.attr( 'href', options.href || '#' )
			.append( label )
			.attr( 'title', title )
			.addClass( 'popups_nopopup' );

		if ( options.click ) {
			$link.on( 'click', options.click );
		}

		return $link;
	}

	// Insert the button next to the right field
	function insertButtons() {
		var $importBtn = makeButton( mw.msg( 'ipl-link-label' ),
			mw.msg( 'ipl-link-title' ), {
				click: activate
			} );
		var $insertImgDash = makeButton( mw.msg( 'ipl-nonnum-label' ),
			mw.msg( 'ipl-nonnum-title' ), {
				click: insertImgDash
			} );
		var $goToPageGame = makeButton( mw.msg( 'ipl-link-page-game' ),
			mw.msg( 'ipl-link-page-game-title' ), {
				href: goToPageGameUrl()
			} );

		var $list = $( '<ul>' )
			.addClass( 'gadgetjs-ipl-toollist' );

		[ $importBtn, $insertImgDash, $goToPageGame ].forEach( function ( $newBtn ) {
			$( '<li>' )
				.append( $newBtn )
				.appendTo( $list );
		} );

		// eslint-disable-next-line no-jquery/no-global-selector
		$( '#wpprpindex-Pages' )
			.closest( '.oo-ui-fieldLayout-body' )
			.children( '.oo-ui-fieldLayout-header' )
			.find( 'label' )
			.append( $list );
	}

	function addCssRule( css ) {
		$( '<style>' ).prop( 'type', 'text/css' ).html( css ).appendTo( 'head' );
	}

	function iplSetup() {

		// Get user config, if any
		mw.hook( gadgetName + '.config' ).fire( IPL );

		IPL.configured = true;

		// only care for editing in the Index: namespace
		if ( !( mw.config.get( 'wgAction' ) === 'edit' || mw.config.get( 'wgAction' ) ===
			'submit' ) ||
		mw.config.get( 'wgCanonicalNamespace' ) !== 'Index' ) {
			return;
		}

		// eslint-disable-next-line no-multi-str
		var css = '\
.gadgetjs-ipl-toollist { \
	list-style: none; \
	font-size: 92%; \
	float: right; \
	text-align: right; \
} \
';
		addCssRule( css );

		insertButtons();
	}

	( function ( $ ) {
		$.fn.getCursorPosition = function () {
			var el = $( this ).get( 0 );
			var pos = 0;
			if ( 'selectionStart' in el ) {
				pos = el.selectionStart;
			} else if ( 'selection' in document ) {
				el.focus();
				var Sel = document.selection.createRange();
				var SelLength = document.selection.createRange().text.length;
				Sel.moveStart( 'character', -el.value.length );
				pos = Sel.text.length - SelLength;
			}
			return pos;
		};

		$.fn.setCursorPosition = function ( start, end ) {
			if ( end === undefined ) {
				end = start;
			}
			return this.each( function () {
				if ( 'selectionStart' in this ) {
					this.selectionStart = start;
					this.selectionEnd = end;
				} else if ( this.setSelectionRange ) {
					this.setSelectionRange( start, end );
				} else if ( this.createTextRange ) {
					var range = this.createTextRange();
					range.collapse( true );
					range.moveEnd( 'character', end );
					range.moveStart( 'character', start );
					range.select();
				}
			} );
		};
	}( $ ) );

	$( iplSetup );

}() );