Gadget-QuoteSourceRedirects.js

From Bodhicitta
MediaWiki:Gadget-QuoteSourceRedirects.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.
/**
 * QuoteSourceRedirects
 *
 * Tools for [[QuoteSource Unresolved Values]].
 *
 * QuoteSource is a page property naming the work a quote segment cites. Where a
 * value does not resolve to a text page, one of four things fixes it:
 *
 *   rewrite   change the value on every segment that uses it
 *   redirect  point the value's page name at a text page
 *   create    create the text page here, for works DRL already holds
 *   delete    remove the value from every segment that uses it
 *
 * Controls are built here rather than written into the page: raw <button> and
 * <input> do not survive the wikitext sanitizer.
 */
( function () {
	'use strict';

	var api;

	function getApi() {
		if ( !api ) { api = new mw.Api(); }
		return api;
	}

	/* Mirrors the page generator's attr(): the wikitext stores each value
	 * HTML-escaped in data-value, so a lookup has to escape it the same way. */
	function attrEscape( s ) {
		return String( s )
			.replace( /&/g, '&amp;' )
			.replace( /"/g, '&quot;' )
			.replace( /</g, '&lt;' )
			.replace( />/g, '&gt;' )
			.replace( /\r\n?|\n/g, '&#10;' )
			.replace( /\|/g, '&#124;' );
	}

	$( function () {
		var $tables = $( '.qs-table' );
		if ( !$tables.length ) { return; }

		/* 27 values contain a newline, which would end the table cell. They are
		 * rendered with a pilcrow and carry the real value in data-value, so
		 * every lookup reads the attribute rather than the cell text. */
		function valueOf( $tr ) {
			return $tr.find( '.qs-actions' ).attr( 'data-value' );
		}

		function setStatus( $tr, cls, text ) {
			$tr.find( '.qs-status' ).removeClass( 'qs-ok qs-err qs-busy' )
				.addClass( cls ).text( text );
		}

		function isTreated( $tr ) {
			return $tr.hasClass( 'qs-treated' );
		}

		function setTreated( $tr ) {
			$tr.addClass( 'qs-treated' );
			$tr.find( 'button' ).prop( 'disabled', true );
			$tr.find( '.qs-dismiss' ).text( 'undo' ).prop( 'disabled', false );
		}

		function clearTreated( $tr ) {
			$tr.removeClass( 'qs-treated' );
			$tr.find( 'button' ).prop( 'disabled', false );
			$tr.find( '.qs-dismiss' ).text( 'done' );
		}

		function countTreated() {
			var n = $( 'tr.qs-treated' ).length;
			var total = $( '.qs-table tr' ).has( 'td' ).length;
			$( '.qs-treated-count' ).text( n ? n + ' of ' + total + ' handled' : '' );
		}

		/* ---------------------------------------------------------------
		 * Marking a row handled, persisted into this page's wikitext.
		 * The row's `|-` becomes `|- class="qs-treated"`, found by the
		 * data-value attribute, which is unique per row.
		 * --------------------------------------------------------------- */
		function markInWikitext( value, add ) {
			var page = mw.config.get( 'wgPageName' );
			return getApi().get( {
				action: 'query', prop: 'revisions', rvprop: 'content',
				rvslots: 'main', titles: page, formatversion: 2
			} ).then( function ( res ) {
				var text = res.query.pages[ 0 ].revisions[ 0 ].slots.main.content;
				var at = text.indexOf( 'data-value="' + attrEscape( value ) + '"' );
				if ( at === -1 ) {
					return $.Deferred().reject( 'row not found in wikitext' );
				}
				var rowStart = text.lastIndexOf( '|-', at );
				var lineEnd = text.indexOf( '\n', rowStart );
				var line = text.slice( rowStart, lineEnd );
				var updated = add
					? ( line.indexOf( 'qs-treated' ) === -1 ? '|- class="qs-treated"' : line )
					: '|-';
				return getApi().postWithToken( 'csrf', {
					action: 'edit', title: page,
					text: text.slice( 0, rowStart ) + updated + text.slice( lineEnd ),
					summary: ( add ? 'mark' : 'unmark' ) + ' QuoteSource row handled: ' + value
				} );
			} );
		}

		/* ---------------------------------------------------------------
		 * Finding the segments that use a value
		 * --------------------------------------------------------------- */
		function segmentsUsing( value ) {
			return getApi().get( {
				action: 'ask',
				query: '[[QuoteSource::' + value + ']]|limit=500',
				formatversion: 2
			} ).then( function ( res ) {
				return Object.keys( ( res.query && res.query.results ) || {} );
			} );
		}

		/* QuoteSource can hold several sources separated by ';', so a value is
		 * changed by exact match on one part, never by replacing the field. */
		function editField( text, from, to ) {
			return text.replace( /(\|\s*QuoteSource\s*=)([^\n]*)/g, function ( all, head, val ) {
				var parts = val.split( ';' );
				var kept = [];
				for ( var i = 0; i < parts.length; i++ ) {
					if ( $.trim( parts[ i ] ) === $.trim( from ) ) {
						if ( to !== null ) { kept.push( to ); }
					} else {
						kept.push( parts[ i ] );
					}
				}
				return head + kept.join( ';' );
			} );
		}

		function editSegments( pages, i, from, to, $tr, done ) {
			if ( i >= pages.length ) {
				setStatus( $tr, 'qs-ok', ( to === null ? 'removed from ' : 'rewrote ' ) +
					pages.length + ' segments' );
				done();
				return;
			}
			setStatus( $tr, 'qs-busy', 'editing ' + ( i + 1 ) + ' of ' + pages.length + '…' );
			getApi().get( {
				action: 'query', prop: 'revisions', rvprop: 'content',
				rvslots: 'main', titles: pages[ i ], formatversion: 2
			} ).done( function ( res ) {
				var pg = res.query.pages[ 0 ];
				if ( !pg || !pg.revisions ) {
					editSegments( pages, i + 1, from, to, $tr, done ); return;
				}
				var text = pg.revisions[ 0 ].slots.main.content;
				var updated = editField( text, from, to );
				if ( updated === text ) {
					editSegments( pages, i + 1, from, to, $tr, done ); return;
				}
				getApi().postWithToken( 'csrf', {
					action: 'edit', title: pages[ i ], text: updated,
					summary: to === null
						? ( 'QuoteSource: remove ' + from )
						: ( 'QuoteSource: ' + from + ' → ' + to )
				} ).done( function () {
					editSegments( pages, i + 1, from, to, $tr, done );
				} ).fail( function ( code ) {
					setStatus( $tr, 'qs-err', code );
				} );
			} ).fail( function ( code ) {
				setStatus( $tr, 'qs-err', code );
			} );
		}

		function runOnSegments( $tr, from, to ) {
			setStatus( $tr, 'qs-busy', 'finding segments…' );
			segmentsUsing( from ).done( function ( pages ) {
				if ( !pages.length ) {
					setStatus( $tr, 'qs-err', 'no segments found' ); return;
				}
				editSegments( pages, 0, from, to, $tr, function () {
					setTreated( $tr );
					countTreated();
					markInWikitext( from, true );
				} );
			} ).fail( function ( code ) {
				setStatus( $tr, 'qs-err', code );
			} );
		}

		/* ---------------------------------------------------------------
		 * The four actions
		 * --------------------------------------------------------------- */
		function doRewrite( $tr ) {
			var from = valueOf( $tr );
			var suggest = $tr.find( '.qs-actions' ).attr( 'data-suggest' ) || from;
			var to = window.prompt(
				'Rewrite this QuoteSource value on every segment that uses it:', suggest );
			if ( to === null || !$.trim( to ) || $.trim( to ) === from ) { return; }
			runOnSegments( $tr, from, $.trim( to ) );
		}

		function doDelete( $tr ) {
			var value = valueOf( $tr );
			if ( !window.confirm(
				'Remove "' + value + '" from every segment that uses it?\n\n' +
				'Other sources on the same segment are kept.' ) ) { return; }
			runOnSegments( $tr, value, null );
		}

		function doRedirect( $tr ) {
			var from = valueOf( $tr );
			var suggest = $tr.find( '.qs-actions' ).attr( 'data-suggest' ) || '';
			var to = window.prompt( 'Redirect "' + from + '" to which page?', suggest );
			if ( to === null || !$.trim( to ) ) { return; }
			to = $.trim( to );
			setStatus( $tr, 'qs-busy', 'creating redirect…' );
			getApi().postWithToken( 'csrf', {
				action: 'edit', title: from,
				text: '#REDIRECT [[' + to + ']]',
				summary: 'QuoteSource redirect to [[' + to + ']]',
				createonly: 1
			} ).done( function () {
				setStatus( $tr, 'qs-ok', 'redirect created' );
				setTreated( $tr ); countTreated(); markInWikitext( from, true );
			} ).fail( function ( code, res ) {
				if ( code === 'articleexists' ) {
					setStatus( $tr, 'qs-ok', 'already exists' );
				} else {
					setStatus( $tr, 'qs-err',
						( res && res.error && res.error.info ) || code );
				}
			} );
		}

		/* The page is created with {{LibraryItem}} and the DRL page name, which
		 * is what pulls the work's data across. Nothing else is guessed here. */
		function doCreate( $tr ) {
			var value = valueOf( $tr );
			var drl = $tr.find( '.qs-actions' ).attr( 'data-drl' );
			if ( !drl ) { setStatus( $tr, 'qs-err', 'no DRL page recorded' ); return; }
			var title = window.prompt(
				'Create the text page here, with {{LibraryItem}}.\n\n' +
				'DRL holds this work as: ' + drl + '\n\nPage name:',
				'Texts/' + drl );
			if ( title === null || !$.trim( title ) ) { return; }
			title = $.trim( title );
			setStatus( $tr, 'qs-busy', 'creating page…' );
			getApi().postWithToken( 'csrf', {
				action: 'edit', title: title,
				text: '{{LibraryItem}}',
				summary: 'Create text page for QuoteSource value "' + value + '"',
				createonly: 1
			} ).done( function () {
				// The value still has to reach the new page: if the value is not
				// the page name itself, it needs a redirect as well.
				if ( value === title ) {
					setStatus( $tr, 'qs-ok', 'page created' );
					setTreated( $tr ); countTreated(); markInWikitext( value, true );
					return;
				}
				getApi().postWithToken( 'csrf', {
					action: 'edit', title: value,
					text: '#REDIRECT [[' + title + ']]',
					summary: 'QuoteSource redirect to [[' + title + ']]',
					createonly: 1
				} ).always( function () {
					setStatus( $tr, 'qs-ok', 'page created, value redirected' );
					setTreated( $tr ); countTreated(); markInWikitext( value, true );
				} );
			} ).fail( function ( code, res ) {
				if ( code === 'articleexists' ) {
					setStatus( $tr, 'qs-err', 'that page already exists' );
				} else {
					setStatus( $tr, 'qs-err',
						( res && res.error && res.error.info ) || code );
				}
			} );
		}

		var ACTIONS = [
			{ key: 'rewrite',  label: 'rewrite',  run: doRewrite },
			{ key: 'redirect', label: 'redirect', run: doRedirect },
			{ key: 'create',   label: 'create',   run: doCreate },
			{ key: 'delete',   label: 'delete',   run: doDelete }
		];

		/* ---------------------------------------------------------------
		 * Build the controls
		 * --------------------------------------------------------------- */
		$( '.qs-actions' ).each( function () {
			var $cell = $( this );
			var $tr = $cell.closest( 'tr' );
			var recommended = $cell.attr( 'data-recommended' );
			var hasDrl = !!$cell.attr( 'data-drl' );

			ACTIONS.forEach( function ( a ) {
				// "create" needs a confirmed DRL page; without one there is
				// nothing to base the new page on.
				if ( a.key === 'create' && !hasDrl ) { return; }
				var $b = $( '<button>' )
					.addClass( 'qs-btn qs-btn--' + a.key )
					.text( a.label )
					.on( 'click', function () {
						if ( isTreated( $tr ) ) { return; }
						a.run( $tr );
					} );
				if ( a.key === recommended ) { $b.addClass( 'qs-btn--suggested' ); }
				$cell.append( $b );
			} );

			$cell.append(
				$( '<button>' ).addClass( 'qs-dismiss' ).text( 'done' )
					.attr( 'title', 'Mark this row handled' )
					.on( 'click', function () {
						var value = valueOf( $tr );
						var add = !isTreated( $tr );
						if ( add ) { setTreated( $tr ); } else { clearTreated( $tr ); }
						countTreated();
						markInWikitext( value, add ).fail( function ( why ) {
							if ( add ) { clearTreated( $tr ); } else { setTreated( $tr ); }
							countTreated();
							setStatus( $tr, 'qs-err', 'could not save: ' + why );
						} );
					} ),
				$( '<span>' ).addClass( 'qs-status' )
			);
		} );

		$( '.qs-controls' ).each( function () {
			var $box = $( this );
			var $table = $box.nextAll( '.qs-table' ).first();
			if ( !$table.length ) { $table = $( '.qs-table' ).first(); }
			var $cb = $( '<input>' ).attr( 'type', 'checkbox' )
				.on( 'change', function () {
					$table.toggleClass( 'qs-hiding', this.checked );
				} );
			$box.append(
				$( '<label>' ).addClass( 'qs-hide-label' ).append( $cb, ' Hide handled rows' ),
				$( '<span>' ).addClass( 'qs-treated-count' )
			);
		} );

		$( 'tr.qs-treated' ).each( function () { setTreated( $( this ) ); } );
		countTreated();
	} );

}() );