MediaWiki:Gadget-QuoteSourceRedirects.js: Difference between revisions

From Bodhicitta
MediaWiki:Gadget-QuoteSourceRedirects.js
(VSCode edit)
(VSCode edit)
Line 17: Line 17:


var api;
var api;
/* Mirrors attr() in the page generator — the wikitext stores the value in
* this form, so a lookup has to escape it the same way. */
function attrEscape( s ) {
return String( s )
.replace( /&/g, '&' )
.replace( /"/g, '"' )
.replace( /</g, '&lt;' )
.replace( />/g, '&gt;' )
.replace( /\r\n?|\n/g, '&#10;' )
.replace( /\|/g, '&#124;' );
}


function getApi() {
function getApi() {
Line 31: Line 43:
function setState( $cell, cls, text ) {
function setState( $cell, cls, text ) {
$cell.removeClass( 'qs-ok qs-err qs-busy' ).addClass( cls ).text( text );
$cell.removeClass( 'qs-ok qs-err qs-busy' ).addClass( cls ).text( text );
}
/* 27 values contain a newline, which would end the table cell. They are
* rendered with a visible pilcrow and carry the real value in
* data-value, so every lookup reads that rather than the cell text. */
function valueOf( $tr ) {
var $v = $tr.find( '.qs-value' ).first();
var d = $v.attr( 'data-value' );
return ( d === undefined || d === null ) ? $v.text() : d;
}
}


Line 107: Line 128:
var pg = res.query.pages[ 0 ];
var pg = res.query.pages[ 0 ];
var text = pg.revisions[ 0 ].slots.main.content;
var text = pg.revisions[ 0 ].slots.main.content;
var needle = '<code class="qs-value">' + value + '</code>';
/* The wikitext holds the value HTML-escaped inside data-value, so
var at = text.indexOf( needle );
* the row is found by that attribute rather than by the cell text. */
var at = text.indexOf( 'data-value="' + attrEscape( value ) + '"' );
if ( at === -1 ) {
if ( at === -1 ) {
return $.Deferred().reject( 'row not found in wikitext' );
return $.Deferred().reject( 'row not found in wikitext' );
Line 128: Line 150:
$tables.on( 'click', '.qs-dismiss', function () {
$tables.on( 'click', '.qs-dismiss', function () {
var $tr = $( this ).closest( 'tr' );
var $tr = $( this ).closest( 'tr' );
var v = $tr.find( '.qs-value' ).text();
var v = valueOf( $tr );
var $status = $tr.find( '.qs-rewrite-status' );
var $status = $tr.find( '.qs-rewrite-status' );
var add = !$tr.hasClass( 'qs-treated' );
var add = !$tr.hasClass( 'qs-treated' );
Line 208: Line 230:
if ( isTreated( $el ) ) { return; }
if ( isTreated( $el ) ) { return; }
var $tr = $el.closest( 'tr' );
var $tr = $el.closest( 'tr' );
var value = $tr.find( '.qs-value' ).text();
var value = valueOf( $tr );
var $status = $tr.find( '.qs-rewrite-status' );
var $status = $tr.find( '.qs-rewrite-status' );
if ( !window.confirm(
if ( !window.confirm(
Line 257: Line 279:
function rewriteValue( $el ) {
function rewriteValue( $el ) {
var $tr = $el.closest( 'tr' );
var $tr = $el.closest( 'tr' );
var from = $tr.find( '.qs-value' ).text();
var from = valueOf( $tr );
var $status = $tr.find( '.qs-rewrite-status' );
var $status = $tr.find( '.qs-rewrite-status' );
var to = window.prompt(
var to = window.prompt(

Revision as of 14:13, 9 September 2026

/**
 * QuoteSourceRedirects
 *
 * Controls for [[BCA:QuoteSource redirects to create]].
 *
 *  - approve   creates a redirect from the QuoteSource value to the target
 *  - edit      changes the target before approving
 *  - rewrite   rewrites the value itself on every segment that uses it
 *  - delete    removes the value from every segment that uses it
 *  - done      marks a row treated, persisted into the page wikitext
 *
 * The "hide treated" control is built here rather than in wikitext: raw
 * <label>/<input>/<button> do not survive the wikitext sanitizer.
 */
( function () {
	'use strict';

	var api;

	/* Mirrors attr() in the page generator — the wikitext stores the value in
	 * this form, 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 getApi() {
		if ( !api ) { api = new mw.Api(); }
		return api;
	}

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

		function setState( $cell, cls, text ) {
			$cell.removeClass( 'qs-ok qs-err qs-busy' ).addClass( cls ).text( text );
		}

		/* 27 values contain a newline, which would end the table cell. They are
		 * rendered with a visible pilcrow and carry the real value in
		 * data-value, so every lookup reads that rather than the cell text. */
		function valueOf( $tr ) {
			var $v = $tr.find( '.qs-value' ).first();
			var d = $v.attr( 'data-value' );
			return ( d === undefined || d === null ) ? $v.text() : d;
		}

		/* A treated row's controls are inert. Checked by every handler, so a
		 * delegated listener cannot fire on a row that has been marked done. */
		function isTreated( $el ) {
			return $el.closest( 'tr' ).hasClass( 'qs-treated' );
		}

		function setTreated( $row ) {
			$row.addClass( 'qs-treated' );
			$row.find( 'button' ).prop( 'disabled', true );
			$row.find( '.qs-value-edit, .qs-value-delete, .qs-target-edit' )
				.addClass( 'qs-disabled' ).attr( 'aria-disabled', 'true' ).removeAttr( 'tabindex' );
			$row.find( '.qs-dismiss' ).text( 'undo' );
		}

		function clearTreated( $row ) {
			$row.removeClass( 'qs-treated' );
			$row.find( 'button' ).prop( 'disabled', false );
			$row.find( '.qs-value-edit, .qs-value-delete, .qs-target-edit' )
				.removeClass( 'qs-disabled' ).removeAttr( 'aria-disabled' ).attr( 'tabindex', '0' );
			$row.find( '.qs-dismiss' ).text( 'done' );
		}

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

		/* -----------------------------------------------------------------
		 * Approving: create the redirect
		 * ----------------------------------------------------------------- */
		function approve( $cell ) {
			var from = $cell.data( 'from' );
			var to = $cell.closest( 'tr' ).find( '.qs-target-label' ).text() || $cell.data( 'to' );
			if ( !from || !to ) {
				setState( $cell, 'qs-err', 'missing data' );
				return;
			}
			setState( $cell, 'qs-busy', 'creating…' );
			getApi().postWithToken( 'csrf', {
				action: 'edit',
				title: from,
				text: '#REDIRECT [[' + to + ']]',
				summary: 'QuoteSource redirect to [[' + to + ']]',
				createonly: 1
			} ).done( function () {
				setState( $cell, 'qs-ok', 'created' );
			} ).fail( function ( code, res ) {
				if ( code === 'articleexists' ) {
					setState( $cell, 'qs-ok', 'already exists' );
				} else if ( res && res.error && res.error.info ) {
					setState( $cell, 'qs-err', res.error.info );
				} else {
					setState( $cell, 'qs-err', code );
				}
			} );
		}

		/* -----------------------------------------------------------------
		 * Persisting the treated state
		 *
		 * The row is marked by editing this page's own wikitext: the `|-` that
		 * opens the row becomes `|- class="qs-treated"`. The row is found by its
		 * QuoteSource value, 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 pg = res.query.pages[ 0 ];
				var text = pg.revisions[ 0 ].slots.main.content;
				/* The wikitext holds the value HTML-escaped inside data-value, so
				 * the row is found by that attribute rather than by the cell text. */
				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 )
					: '|-';
				var newText = text.slice( 0, rowStart ) + updated + text.slice( lineEnd );
				return getApi().postWithToken( 'csrf', {
					action: 'edit', title: page, text: newText,
					summary: ( add ? 'mark' : 'unmark' ) + ' QuoteSource row treated: ' + value
				} );
			} );
		}

		$tables.on( 'click', '.qs-dismiss', function () {
			var $tr = $( this ).closest( 'tr' );
			var v = valueOf( $tr );
			var $status = $tr.find( '.qs-rewrite-status' );
			var add = !$tr.hasClass( 'qs-treated' );

			if ( add ) { setTreated( $tr ); } else { clearTreated( $tr ); }
			countTreated();

			markInWikitext( v, add ).fail( function ( why ) {
				if ( add ) { clearTreated( $tr ); } else { setTreated( $tr ); }
				countTreated();
				$status.removeClass( 'qs-busy qs-ok' ).addClass( 'qs-err' )
					.text( 'could not save: ' + why );
			} );
		} );

		/* -----------------------------------------------------------------
		 * Removing a value entirely
		 *
		 * QuoteSource can hold several sources separated by ';', so the value is
		 * removed by exact match and the separators tidied, never by blanking
		 * the field.
		 * ----------------------------------------------------------------- */
		function stripValue( field, value ) {
			var parts = field.split( ';' );
			var kept = [];
			var i, p;
			for ( i = 0; i < parts.length; i++ ) {
				p = parts[ i ];
				if ( $.trim( p ) !== $.trim( value ) ) {
					kept.push( p );
				}
			}
			return kept.join( ';' );
		}

		function removeOnPage( pages, i, value, $status, done ) {
			if ( i >= pages.length ) {
				$status.removeClass( 'qs-busy' ).addClass( 'qs-ok' )
					.text( 'removed from ' + pages.length + ' segments' );
				done();
				return;
			}
			$status.text( '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 ) {
					removeOnPage( pages, i + 1, value, $status, done );
					return;
				}
				var text = pg.revisions[ 0 ].slots.main.content;
				var updated = text.replace(
					/(\|\s*QuoteSource\s*=)([^\n|]*)/g,
					function ( all, head, val ) {
						return head + stripValue( val, value );
					}
				);
				if ( updated === text ) {
					removeOnPage( pages, i + 1, value, $status, done );
					return;
				}
				getApi().postWithToken( 'csrf', {
					action: 'edit', title: pages[ i ], text: updated,
					summary: 'QuoteSource: remove ' + value
				} ).done( function () {
					removeOnPage( pages, i + 1, value, $status, done );
				} ).fail( function ( code ) {
					$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
				} );
			} ).fail( function ( code ) {
				$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
			} );
		}

		$tables.on( 'click', '.qs-value-delete', function () {
			var $el = $( this );
			if ( isTreated( $el ) ) { return; }
			var $tr = $el.closest( 'tr' );
			var value = valueOf( $tr );
			var $status = $tr.find( '.qs-rewrite-status' );
			if ( !window.confirm(
				'Remove "' + value + '" from every segment that uses it?\n\n' +
				'Other sources on the same segment are kept.'
			) ) { return; }

			$status.removeClass( 'qs-ok qs-err' ).addClass( 'qs-busy' ).text( 'finding segments…' );
			getApi().get( {
				action: 'ask',
				query: '[[QuoteSource::' + value + ']]|limit=500',
				formatversion: 2
			} ).done( function ( res ) {
				var results = ( res.query && res.query.results ) || {};
				var pages = Object.keys( results );
				if ( !pages.length ) {
					$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( 'no segments found' );
					return;
				}
				removeOnPage( pages, 0, value, $status, function () {
					setTreated( $tr );
					countTreated();
					markInWikitext( value, true );
				} );
			} ).fail( function ( code ) {
				$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
			} );
		} );

		/* -----------------------------------------------------------------
		 * Editing the target
		 * ----------------------------------------------------------------- */
		$tables.on( 'click', '.qs-target-edit', function () {
			var $el = $( this );
			if ( isTreated( $el ) ) { return; }
			var $span = $el.closest( '.qs-target' );
			var $label = $span.find( '.qs-target-label' );
			var current = $label.text();
			var next = window.prompt( 'Redirect target for this value:', current );
			if ( next === null || next === current || !$.trim( next ) ) { return; }
			$label.text( $.trim( next ) );
			$span.closest( 'tr' ).find( '.qs-approve' ).data( 'to', $.trim( next ) );
		} );

		/* -----------------------------------------------------------------
		 * Rewriting the QuoteSource value on the segments
		 * ----------------------------------------------------------------- */
		function rewriteValue( $el ) {
			var $tr = $el.closest( 'tr' );
			var from = valueOf( $tr );
			var $status = $tr.find( '.qs-rewrite-status' );
			var to = window.prompt(
				'Rewrite this QuoteSource value on every segment that uses it:', from
			);
			if ( to === null || to === from || !$.trim( to ) ) { return; }
			to = $.trim( to );

			$status.removeClass( 'qs-ok qs-err' ).addClass( 'qs-busy' ).text( 'finding segments…' );
			getApi().get( {
				action: 'ask',
				query: '[[QuoteSource::' + from + ']]|limit=500',
				formatversion: 2
			} ).done( function ( res ) {
				var results = ( res.query && res.query.results ) || {};
				var pages = Object.keys( results );
				if ( !pages.length ) {
					$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( 'no segments found' );
					return;
				}
				editNext( pages, 0, from, to, $status );
			} ).fail( function ( code ) {
				$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
			} );
		}

		function editNext( pages, i, from, to, $status ) {
			if ( i >= pages.length ) {
				$status.removeClass( 'qs-busy' ).addClass( 'qs-ok' )
					.text( 'rewrote ' + pages.length + ' segments' );
				return;
			}
			$status.text( '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 ) {
					editNext( pages, i + 1, from, to, $status );
					return;
				}
				var text = pg.revisions[ 0 ].slots.main.content;
				var updated = text.split( from ).join( to );
				if ( updated === text ) {
					editNext( pages, i + 1, from, to, $status );
					return;
				}
				getApi().postWithToken( 'csrf', {
					action: 'edit', title: pages[ i ], text: updated,
					summary: 'QuoteSource: ' + from + ' → ' + to
				} ).done( function () {
					editNext( pages, i + 1, from, to, $status );
				} ).fail( function ( code ) {
					$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
				} );
			} ).fail( function ( code ) {
				$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
			} );
		}

		$tables.on( 'click', '.qs-value-edit', function () {
			var $el = $( this );
			if ( isTreated( $el ) ) { return; }
			rewriteValue( $el );
		} );

		/* -----------------------------------------------------------------
		 * Build the controls
		 * ----------------------------------------------------------------- */
		$( '.qs-approve' ).each( function () {
			var $cell = $( this );
			$cell.append(
				$( '<button>' ).addClass( 'qs-btn' ).text( 'approve' )
					.on( 'click', function () {
						if ( isTreated( $cell ) ) { return; }
						approve( $cell );
					} )
			);
		} );

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

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

	} );

}() );