MediaWiki:Gadget-QuoteSourceRedirects.js: Difference between revisions

From Bodhicitta
MediaWiki:Gadget-QuoteSourceRedirects.js
(VSCode edit)
(VSCode edit)
Line 73: Line 73:
//    localStorage. It is a working note, not data: losing it costs a
//    localStorage. It is a working note, not data: losing it costs a
//    second look, not information.
//    second look, not information.
var DISMISS_KEY = 'qs-dismissed';
// Treated rows are recorded on a wiki subpage, not in localStorage: this
// is a shared worklist, so a mark has to survive a different browser and
// be visible to whoever picks the list up next. One value per line.
var STORE = 'QuoteSource redirects to create/Treated';
var treated = [];
var treatedRev = null;


function loadDismissed() {
function loadTreated( then ) {
try {
api.get( { action: 'query', prop: 'revisions', rvprop: 'content|ids',
return JSON.parse( mw.storage.get( DISMISS_KEY ) || '[]' );
rvslots: 'main', titles: STORE, format: 'json', formatversion: 2 }
} catch ( e ) {
).done( function ( res ) {
return [];
var page = ( ( res.query && res.query.pages ) || [] )[ 0 ] || {};
}
var text = page.revisions && page.revisions[ 0 ].slots.main.content;
treatedRev = page.revisions && page.revisions[ 0 ].revid;
treated = text ? text.split( '\n' )
.map( function ( l ) { return $.trim( l.replace( /^\*\s*/, '' ) ); } )
.filter( Boolean ) : [];
then();
} ).fail( function () { treated = []; then(); } );
}
}


function saveDismissed( list ) {
function saveTreated( $status ) {
try {
var text = treated.map( function ( v ) { return '* ' + v; } ).join( '\n' );
mw.storage.set( DISMISS_KEY, JSON.stringify( list ) );
return api.postWithToken( 'csrf', {
} catch ( e ) {}
action: 'edit', title: STORE,
text: '<!-- QuoteSource values marked treated. One per line. -->\n' + text,
summary: 'Update treated QuoteSource values (' + treated.length + ')'
} ).fail( function ( code ) {
if ( $status ) { $status.text( 'could not save: ' + code ); }
} );
}
}


Line 99: Line 115:
$row.find( 'button' ).prop( 'disabled', false );
$row.find( 'button' ).prop( 'disabled', false );
$row.find( '.qs-value-edit, .qs-target-edit' ).removeClass( 'qs-disabled' );
$row.find( '.qs-value-edit, .qs-target-edit' ).removeClass( 'qs-disabled' );
}
// Ask the wiki which rows are done, in batches of 40.
//
// A row is done when its value resolves to a page — OR when no segment
// carries that value any more, which is what a rewrite leaves behind. The
// second test matters: rewriting "Śikṣāsamuccaya (RKTST 3281)." to drop a
// stray full stop leaves the old value pointing at nothing and used by
// nothing. It is treated, but it will never resolve.
//
// SMW reindexes on its own schedule, so a just-rewritten value can still
// report segments for a while. That only delays the row greying out; it
// never marks an untreated row done.
function refreshResolved() {
var $cells = $( '.qs-approve, .qs-row' );
var values = [];
$( '.qs-table tr' ).each( function () {
var v = $( this ).find( '.qs-value' ).text();
if ( v ) { values.push( $.trim( v ) ); }
} );
if ( !values.length ) { return; }
var resolved = {};
function batch( i ) {
if ( i >= values.length ) {
markResolved( resolved, values );
return;
}
var slice = values.slice( i, i + 40 );
api.get( { action: 'query', titles: slice.join( '|' ), redirects: 1,
format: 'json', formatversion: 2 }
).done( function ( res ) {
var q = res.query || {};
( q.redirects || [] ).forEach( function ( r ) { resolved[ r.from ] = true; } );
( q.pages || [] ).forEach( function ( p ) { if ( !p.missing ) { resolved[ p.title ] = true; } } );
( q.normalized || [] ).forEach( function ( n ) {
if ( resolved[ n.to ] ) { resolved[ n.from ] = true; }
} );
batch( i + 40 );
} ).fail( function () { batch( i + 40 ); } );
}
batch( 0 );
}
// Second pass: values no longer used by any segment are also done.
function markResolved( resolved, values ) {
var unresolved = values.filter( function ( v ) { return !resolved[ v ]; } );
var done = 0;
function finish() {
$( '.qs-table tr' ).each( function () {
var $tr = $( this );
var v = $.trim( $tr.find( '.qs-value' ).text() );
if ( v && resolved[ v ] ) {
setTreated( $tr, resolved[ v ] === 'unused' ? 'rewritten' : 'resolved' );
done++;
}
} );
$( '.qs-resolved-count' ).text( done + ' of ' + values.length + ' treated' );
}
if ( !unresolved.length ) { finish(); return; }
// One ask per value is too many requests; batch with an OR chain.
// The value repeats, not the property — [[P::a||b||c]].
function batch( i ) {
if ( i >= unresolved.length ) { finish(); return; }
var slice = unresolved.slice( i, i + 20 );
api.get( {
action: 'ask',
query: '[[QuoteSource::' +
slice.map( function ( v ) { return v.replace( /[[\]|]/g, '' ); } ).join( '||' ) +
']]|?QuoteSource|limit=500',
format: 'json'
} ).done( function ( res ) {
var seen = {};
var results = ( res.query && res.query.results ) || {};
Object.keys( results ).forEach( function ( k ) {
var po = results[ k ].printouts || {};
( po.QuoteSource || [] ).forEach( function ( x ) {
String( x ).split( /\s*;\s*/ ).forEach( function ( part ) {
seen[ $.trim( part.replace( /[\n\r]+/g, ' ' ) ) ] = true;
} );
} );
} );
slice.forEach( function ( v ) {
if ( !seen[ v ] ) { resolved[ v ] = 'unused'; }
} );
batch( i + 20 );
} ).fail( function () { batch( i + 20 ); } );
}
batch( 0 );
}
}


Line 196: Line 120:
var $tr = $( this ).closest( 'tr' );
var $tr = $( this ).closest( 'tr' );
var v = $.trim( $tr.find( '.qs-value' ).text() );
var v = $.trim( $tr.find( '.qs-value' ).text() );
var list = loadDismissed();
if ( !v ) { return; }
var at = list.indexOf( v );
var $status = $( '.qs-resolved-count' );
var at = treated.indexOf( v );
if ( at === -1 ) {
if ( at === -1 ) {
list.push( v );
treated.push( v );
setTreated( $tr, 'dismissed' );
setTreated( $tr, 'treated' );
} else {
} else {
list.splice( at, 1 );
treated.splice( at, 1 );
clearTreated( $tr );
clearTreated( $tr );
}
}
saveDismissed( list );
$status.text( 'saving…' );
saveTreated( $status ).done( function () {
$status.text( treated.length + ' marked treated' );
} );
} );
} );


// Apply stored dismissals, then ask the wiki about the rest.
loadTreated( function () {
( function () {
if ( !treated.length ) { return; }
var dismissed = loadDismissed();
$( '.qs-table tr' ).each( function () {
if ( dismissed.length ) {
var $tr = $( this );
$( '.qs-table tr' ).each( function () {
var v = $.trim( $tr.find( '.qs-value' ).text() );
var $tr = $( this );
if ( v && treated.indexOf( v ) !== -1 ) { setTreated( $tr, 'treated' ); }
var v = $.trim( $tr.find( '.qs-value' ).text() );
} );
if ( v && dismissed.indexOf( v ) !== -1 ) { setTreated( $tr, 'dismissed' ); }
$( '.qs-resolved-count' ).text( treated.length + ' marked treated' );
} );
} );
}
refreshResolved();
}() );


$( '.qs-hide-treated' ).on( 'change', function () {
$( '.qs-hide-treated' ).on( 'change', function () {

Revision as of 12:47, 9 September 2026

/**
 * Approve buttons for [[QuoteSource redirects to create]].
 *
 * Each suggested row carries a .qs-approve span with data-from (the unresolved
 * QuoteSource value) and data-to (the target page). Clicking creates a redirect
 * from one to the other through the API — it does not touch any segment.
 *
 * Deliberately createonly: if a page already exists at that title the edit is
 * refused rather than overwriting it, so an approval can never clobber real
 * content. The row reports what happened either way.
 *
 * Only loaded on that page, and only for users who can edit — the buttons are
 * not rendered at all otherwise.
 */
( function () {
	'use strict';

	if ( mw.config.get( 'wgPageName' ) !== 'QuoteSource_redirects_to_create' ) {
		return;
	}

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

		var api = new mw.Api();

		// Replaces the button with a result. The button is gone by then, so the
		// row cannot be double-submitted.
		function setState( $cell, cls, text ) {
			$cell.removeClass( 'qs-pending qs-done qs-failed' ).addClass( cls ).text( text );
		}

		function approve( $cell ) {
			var from = $cell.data( 'from' );
			var to = $cell.data( 'to' );
			if ( !from || !to ) {
				return;
			}

			setState( $cell, 'qs-pending', 'creating…' );

			api.postWithToken( 'csrf', {
				action: 'edit',
				title: from,
				text: '#REDIRECT [[' + to + ']]',
				summary: 'Redirect for a QuoteSource value used in the translation memory',
				createonly: 1
			} ).done( function () {
				setState( $cell, 'qs-done', '✓ created' );
			} ).fail( function ( code, res ) {
				var why = code;
				if ( code === 'articleexists' ) {
					why = 'page already exists';
				} else if ( res && res.error && res.error.info ) {
					why = res.error.info;
				}
				setState( $cell, 'qs-failed', '✗ ' + why );
			} );
		}

		// Marking a row treated.
		//
		// Two kinds of "done" and they behave differently:
		//
		//   - RESOLVED: the value now points at a page. That is a fact about the
		//     wiki, so it is re-checked on load and needs no storage — a redirect
		//     created in a previous session shows as done next time.
		//   - DISMISSED: a judgement that a row needs nothing, or nothing we can
		//     do. Nothing on the wiki records that, so it is kept per-user in
		//     localStorage. It is a working note, not data: losing it costs a
		//     second look, not information.
		// Treated rows are recorded on a wiki subpage, not in localStorage: this
		// is a shared worklist, so a mark has to survive a different browser and
		// be visible to whoever picks the list up next. One value per line.
		var STORE = 'QuoteSource redirects to create/Treated';
		var treated = [];
		var treatedRev = null;

		function loadTreated( then ) {
			api.get( { action: 'query', prop: 'revisions', rvprop: 'content|ids',
				rvslots: 'main', titles: STORE, format: 'json', formatversion: 2 }
			).done( function ( res ) {
				var page = ( ( res.query && res.query.pages ) || [] )[ 0 ] || {};
				var text = page.revisions && page.revisions[ 0 ].slots.main.content;
				treatedRev = page.revisions && page.revisions[ 0 ].revid;
				treated = text ? text.split( '\n' )
					.map( function ( l ) { return $.trim( l.replace( /^\*\s*/, '' ) ); } )
					.filter( Boolean ) : [];
				then();
			} ).fail( function () { treated = []; then(); } );
		}

		function saveTreated( $status ) {
			var text = treated.map( function ( v ) { return '* ' + v; } ).join( '\n' );
			return api.postWithToken( 'csrf', {
				action: 'edit', title: STORE,
				text: '<!-- QuoteSource values marked treated. One per line. -->\n' + text,
				summary: 'Update treated QuoteSource values (' + treated.length + ')'
			} ).fail( function ( code ) {
				if ( $status ) { $status.text( 'could not save: ' + code ); }
			} );
		}

		function setTreated( $row, why ) {
			$row.addClass( 'qs-treated' ).attr( 'data-treated', why );
			$row.find( 'button' ).prop( 'disabled', true );
			$row.find( '.qs-value-edit, .qs-target-edit' ).addClass( 'qs-disabled' );
		}

		function clearTreated( $row ) {
			$row.removeClass( 'qs-treated' ).removeAttr( 'data-treated' );
			$row.find( 'button' ).prop( 'disabled', false );
			$row.find( '.qs-value-edit, .qs-target-edit' ).removeClass( 'qs-disabled' );
		}

		$( '.qs-table' ).on( 'click', '.qs-dismiss', function () {
			var $tr = $( this ).closest( 'tr' );
			var v = $.trim( $tr.find( '.qs-value' ).text() );
			if ( !v ) { return; }
			var $status = $( '.qs-resolved-count' );
			var at = treated.indexOf( v );
			if ( at === -1 ) {
				treated.push( v );
				setTreated( $tr, 'treated' );
			} else {
				treated.splice( at, 1 );
				clearTreated( $tr );
			}
			$status.text( 'saving…' );
			saveTreated( $status ).done( function () {
				$status.text( treated.length + ' marked treated' );
			} );
		} );

		loadTreated( function () {
			if ( !treated.length ) { return; }
			$( '.qs-table tr' ).each( function () {
				var $tr = $( this );
				var v = $.trim( $tr.find( '.qs-value' ).text() );
				if ( v && treated.indexOf( v ) !== -1 ) { setTreated( $tr, 'treated' ); }
			} );
			$( '.qs-resolved-count' ).text( treated.length + ' marked treated' );
		} );

		$( '.qs-hide-treated' ).on( 'change', function () {
			$( '.qs-table' ).toggleClass( 'qs-hiding', this.checked );
		} );

		// Create every redirect in a table at once.
		//
		// Sequential, not parallel: a hundred simultaneous edits would trip rate
		// limits, and stopping cleanly on the first failure is more useful than
		// discovering afterwards which of them landed. Rows already created are
		// skipped, so it is safe to run again after a failure.
		$( '.qs-bulk-btn' ).on( 'click', function () {
			var $btn = $( this );
			var $status = $btn.siblings( '.qs-bulk-status' );
			var $table = $btn.closest( '.qs-bulk' ).next( '.qs-table' );
			var $todo = $table.find( '.qs-approve' ).filter( function () {
				return !$( this ).hasClass( 'qs-done' );
			} );
			if ( !$todo.length ) {
				$status.text( 'nothing left to create' );
				return;
			}
			if ( !window.confirm(
				'Create ' + $todo.length + ' redirects?\n\n' +
				'Each points a QuoteSource value at its suggested target. ' +
				'Existing pages are never overwritten.'
			) ) {
				return;
			}
			$btn.prop( 'disabled', true );

			var i = 0;
			function next() {
				if ( i >= $todo.length ) {
					$status.text( '✓ done (' + $todo.length + ')' );
					$btn.prop( 'disabled', false );
					return;
				}
				var $cell = $todo.eq( i );
				$status.text( 'creating ' + ( i + 1 ) + ' of ' + $todo.length + '…' );
				$cell.find( '.qs-approve-btn' ).prop( 'disabled', true );
				api.postWithToken( 'csrf', {
					action: 'edit',
					title: $cell.data( 'from' ),
					text: '#REDIRECT [[' + $cell.data( 'to' ) + ']]',
					summary: 'Redirect for a QuoteSource value used in the translation memory',
					createonly: 1
				} ).done( function () {
					setState( $cell, 'qs-done', '✓ created' );
					i++; next();
				} ).fail( function ( code ) {
					setState( $cell, 'qs-failed', '✗ ' + ( code === 'articleexists' ? 'exists' : code ) );
					// Keep going: one existing page should not stop the batch.
					i++; next();
				} );
			}
			next();
		} );

		$rows.each( function () {
			var $cell = $( this );
			$( '<button>' )
				.addClass( 'qs-approve-btn' )
				.text( 'Create redirect' )
				.on( 'click', function () {
					$( this ).prop( 'disabled', true );
					approve( $cell );
				} )
				.appendTo( $cell );
		} );

		// Rewriting the stored value across every segment that uses it.
		//
		// A redirect fixes the link without touching the data; this is for when
		// the value itself is wrong — a misspelling, or a form we do not want to
		// keep. It edits every segment carrying that value, so it asks for
		// confirmation with the count and reports progress as it goes.
		//
		// Replacement is on the exact value within the parameter, not the whole
		// line: 119 segments name several sources separated by semicolons, and
		// rewriting the line would destroy the others.
		function rewriteValue( $cell ) {
			var from = $cell.data( 'from' );
			var to = window.prompt(
				'Rewrite this QuoteSource value on every segment that uses it.\n\n' +
				'This edits the segments themselves, not just a redirect.\n\n' +
				'Current value:',
				from
			);
			if ( to === null ) {
				return;
			}
			to = $.trim( to );
			if ( !to || to === from ) {
				return;
			}

			var $status = $cell.find( '.qs-rewrite-status' );
			$status.text( 'finding segments…' );

			api.get( {
				action: 'ask',
				query: '[[QuoteSource::' + from.replace( /[[\]|]/g, '' ) + ']]|limit=500',
				format: 'json'
			} ).done( function ( res ) {
				var results = ( res.query && res.query.results ) || {};
				var pages = Object.keys( results );
				if ( !pages.length ) {
					$status.text( 'no segments matched' );
					return;
				}
				if ( !window.confirm(
					'Rewrite on ' + pages.length + ' segment' + ( pages.length === 1 ? '' : 's' ) + '?\n\n' +
					'"' + from + '"\n  ->  "' + to + '"'
				) ) {
					$status.text( '' );
					return;
				}
				editNext( pages, 0, from, to, $status );
			} ).fail( function ( code ) {
				$status.text( 'lookup failed: ' + code );
			} );
		}

		// One at a time, so a failure stops rather than half-finishing a batch.
		function editNext( pages, i, from, to, $status ) {
			if ( i >= pages.length ) {
				$status.text( '✓ rewrote ' + pages.length );
				return;
			}
			$status.text( 'editing ' + ( i + 1 ) + ' of ' + pages.length + '…' );

			api.get( { action: 'query', prop: 'revisions', rvprop: 'content',
				rvslots: 'main', titles: pages[ i ], format: 'json', formatversion: 2 }
			).done( function ( res ) {
				var page = ( ( res.query && res.query.pages ) || [] )[ 0 ];
				var text = page && page.revisions && page.revisions[ 0 ].slots.main.content;
				if ( !text ) {
					$status.text( '✗ could not read ' + pages[ i ] );
					return;
				}
				// Exact value replacement, leaving any other sources on the line.
				if ( text.indexOf( from ) === -1 ) {
					// Already changed, or stored differently — skip rather than guess.
					editNext( pages, i + 1, from, to, $status );
					return;
				}
				var updated = text.split( from ).join( to );
				api.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.text( '✗ ' + pages[ i ] + ': ' + code );
				} );
			} ).fail( function ( code ) {
				$status.text( '✗ ' + code );
			} );
		}

		$( '.qs-table' ).on( 'click', '.qs-value-edit', function () {
			rewriteValue( $( this ).closest( 'tr' ).find( '.qs-approve' ) );
		} );

		// Editing a target in place: the suggestion is a guess, and the row is
		// useless if the only way to correct it is to edit 67KB of wikitext.
		$( '.qs-table' ).on( 'click', '.qs-target-edit', function () {
			var $link = $( this );
			var $cell = $link.closest( 'td' );
			var $approve = $cell.closest( 'tr' ).find( '.qs-approve' );
			var current = $approve.data( 'to' ) || '';
			var value = window.prompt(
				'Redirect target for:\n\n' + ( $approve.data( 'from' ) || '' ),
				current
			);
			if ( value === null ) {
				return;
			}
			value = $.trim( value );
			if ( !value ) {
				return;
			}
			$approve.data( 'to', value );
			$cell.find( '.qs-target-label' ).text( value );
			// Re-arm the row: a changed target means the previous result, whether
			// it succeeded or failed, no longer describes what the button does.
			$approve.removeClass( 'qs-pending qs-done qs-failed' ).empty();
			$( '<button>' )
				.addClass( 'qs-approve-btn' )
				.text( 'Create redirect' )
				.on( 'click', function () {
					$( this ).prop( 'disabled', true );
					approve( $approve );
				} )
				.appendTo( $approve );
		} );
	} );
}() );