MediaWiki:Gadget-QuoteSourceRedirects.js: Difference between revisions

From Bodhicitta
MediaWiki:Gadget-QuoteSourceRedirects.js
(VSCode edit)
(VSCode edit)
Line 1: Line 1:
/**
/**
  * Approve buttons for [[QuoteSource redirects to create]].
  * QuoteSourceRedirects
  *
  *
  * Each suggested row carries a .qs-approve span with data-from (the unresolved
  * Controls for [[BCA:QuoteSource redirects to create]].
* 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
  * - approve  creates a redirect from the QuoteSource value to the target
  * refused rather than overwriting it, so an approval can never clobber real
*  - edit     changes the target before approving
  * content. The row reports what happened either way.
  * - 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
  *
  *
  * Only loaded on that page, and only for users who can edit — the buttons are
  * The "hide treated" control is built here rather than in wikitext: raw
  * not rendered at all otherwise.
  * <label>/<input>/<button> do not survive the wikitext sanitizer.
  */
  */
( function () {
( function () {
'use strict';
'use strict';


if ( mw.config.get( 'wgPageName' ) !== 'QuoteSource_redirects_to_create' ) {
var api;
return;
 
function getApi() {
if ( !api ) { api = new mw.Api(); }
return api;
}
}


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


var api = new mw.Api();
function setState( $cell, cls, text ) {
$cell.removeClass( 'qs-ok qs-err qs-busy' ).addClass( cls ).text( text );
}
 
/* 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' );
}


// Replaces the button with a result. The button is gone by then, so the
function countTreated() {
// row cannot be double-submitted.
var n = $( 'tr.qs-treated' ).length;
function setState( $cell, cls, text ) {
$( '.qs-treated-count' ).text(
$cell.removeClass( 'qs-pending qs-done qs-failed' ).addClass( cls ).text( text );
n ? n + ' of ' + $( '.qs-table tr' ).has( 'td' ).length + ' marked treated' : ''
);
}
}


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


// Marking a row treated.
/* -----------------------------------------------------------------
//
* Persisting the treated state
// Two kinds of "done" and they behave differently:
*
//
* The row is marked by editing this page's own wikitext: the `|-` that
//  - RESOLVED: the value now points at a page. That is a fact about the
* opens the row becomes `|- class="qs-treated"`. The row is found by its
//    wiki, so it is re-checked on load and needs no storage — a redirect
* QuoteSource value, which is unique per row.
//    created in a previous session shows as done next time.
* ----------------------------------------------------------------- */
//  - DISMISSED: a judgement that a row needs nothing, or nothing we can
function markInWikitext( value, add ) {
//    do. Nothing on the wiki records that, so it is kept per-user in
var page = mw.config.get( 'wgPageName' );
//    localStorage. It is a working note, not data: losing it costs a
return getApi().get( {
//    second look, not information.
action: 'query', prop: 'revisions', rvprop: 'content',
// Treated rows are marked in the page's own wikitext: the row's "|-" gains
rvslots: 'main', titles: page, formatversion: 2
// a class, so the state lives with the row it describes rather than in a
} ).then( function ( res ) {
// parallel list that can drift out of step with it.
var pg = res.query.pages[ 0 ];
//
var text = pg.revisions[ 0 ].slots.main.content;
// Not localStorage: this is a shared worklist, and a mark has to survive
// a different browser and be visible to whoever picks it up next.
function markInWikitext( value, add, $status ) {
var title = mw.config.get( 'wgPageName' ).replace( /_/g, ' ' );
$status.text( 'saving…' );
return api.get( { action: 'query', prop: 'revisions', rvprop: 'content',
rvslots: 'main', titles: title, format: 'json', formatversion: 2 }
).then( function ( res ) {
var page = ( ( res.query && res.query.pages ) || [] )[ 0 ] || {};
var text = page.revisions && page.revisions[ 0 ].slots.main.content;
if ( !text ) { return $.Deferred().reject( 'could not read page' ); }
 
// Find the row by its value cell, then rewrite the "|-" above it.
var needle = '<code class="qs-value">' + value + '</code>';
var needle = '<code class="qs-value">' + value + '</code>';
var at = text.indexOf( needle );
var at = text.indexOf( needle );
if ( at === -1 ) { return $.Deferred().reject( 'row not found' ); }
if ( at === -1 ) {
return $.Deferred().reject( 'row not found in wikitext' );
}
var rowStart = text.lastIndexOf( '|-', at );
var rowStart = text.lastIndexOf( '|-', at );
if ( rowStart === -1 ) { return $.Deferred().reject( 'row start not found' ); }
var lineEnd = text.indexOf( '\n', rowStart );
var lineEnd = text.indexOf( '\n', rowStart );
var line = text.slice( rowStart, lineEnd );
var line = text.slice( rowStart, lineEnd );
Line 100: Line 118:
? ( line.indexOf( 'qs-treated' ) === -1 ? '|- class="qs-treated"' : line )
? ( line.indexOf( 'qs-treated' ) === -1 ? '|- class="qs-treated"' : line )
: '|-';
: '|-';
if ( updated === line ) { return $.Deferred().resolve(); }
var newText = text.slice( 0, rowStart ) + updated + text.slice( lineEnd );
 
return getApi().postWithToken( 'csrf', {
return api.postWithToken( 'csrf', {
action: 'edit', title: page, text: newText,
action: 'edit', title: title,
summary: ( add ? 'mark' : 'unmark' ) + ' QuoteSource row treated: ' + value
text: text.slice( 0, rowStart ) + updated + text.slice( lineEnd ),
summary: ( add ? 'Mark treated: ' : 'Unmark treated: ' ) + value
} );
} );
} );
} );
}
}


 
$tables.on( 'click', '.qs-dismiss', function () {
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 $tr = $( this ).closest( 'tr' );
var v = $.trim( $tr.find( '.qs-value' ).text() );
var v = $tr.find( '.qs-value' ).text();
if ( !v ) { return; }
var $status = $tr.find( '.qs-rewrite-status' );
var $status = $( '.qs-resolved-count' );
var add = !$tr.hasClass( 'qs-treated' );
var add = !$tr.hasClass( 'qs-treated' );


// Optimistic: the row responds at once, and reverts if the save fails.
if ( add ) { setTreated( $tr ); } else { clearTreated( $tr ); }
if ( add ) { setTreated( $tr, 'treated' ); } else { clearTreated( $tr ); }
countTreated();


markInWikitext( v, add, $status ).done( function () {
markInWikitext( v, add ).fail( function ( why ) {
$status.text( $( 'tr.qs-treated' ).length + ' marked treated' );
if ( add ) { clearTreated( $tr ); } else { setTreated( $tr ); }
} ).fail( function ( why ) {
countTreated();
if ( add ) { clearTreated( $tr ); } else { setTreated( $tr, 'treated' ); }
$status.removeClass( 'qs-busy qs-ok' ).addClass( 'qs-err' )
$status.text( 'could not save: ' + ( why || 'error' ) );
.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( ';' );
}


// Rows already carrying the class come straight from the wikitext; they
function removeOnPage( pages, i, value, $status, done ) {
// only need their controls disabling.
if ( i >= pages.length ) {
$( 'tr.qs-treated' ).each( function () { setTreated( $( this ), 'treated' ); } );
$status.removeClass( 'qs-busy' ).addClass( 'qs-ok' )
$( '.qs-resolved-count' ).text( $( 'tr.qs-treated' ).length + ' marked treated' );
.text( 'removed from ' + pages.length + ' segments' );
 
done();
 
$( '.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;
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 = $tr.find( '.qs-value' ).text();
var $status = $tr.find( '.qs-rewrite-status' );
if ( !window.confirm(
if ( !window.confirm(
'Create ' + $todo.length + ' redirects?\n\n' +
'Remove "' + value + '" from every segment that uses it?\n\n' +
'Each points a QuoteSource value at its suggested target. ' +
'Other sources on the same segment are kept.'
'Existing pages are never overwritten.'
) ) { return; }
) ) {
return;
}
$btn.prop( 'disabled', true );


var i = 0;
$status.removeClass( 'qs-ok qs-err' ).addClass( 'qs-busy' ).text( 'finding segments…' );
function next() {
getApi().get( {
if ( i >= $todo.length ) {
action: 'ask',
$status.text( '✓ done (' + $todo.length + ')' );
query: '[[QuoteSource::' + value + ']]|limit=500',
$btn.prop( 'disabled', false );
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;
return;
}
}
var $cell = $todo.eq( i );
removeOnPage( pages, 0, value, $status, function () {
$status.text( 'creating ' + ( i + 1 ) + ' of ' + $todo.length + '…' );
setTreated( $tr );
$cell.find( '.qs-approve-btn' ).prop( 'disabled', true );
countTreated();
api.postWithToken( 'csrf', {
markInWikitext( value, true );
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();
} );
} );
}
} ).fail( function ( code ) {
next();
$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
} );
} );
} );


$rows.each( function () {
/* -----------------------------------------------------------------
var $cell = $( this );
* Editing the target
$( '<button>' )
* ----------------------------------------------------------------- */
.addClass( 'qs-approve-btn' )
$tables.on( 'click', '.qs-target-edit', function () {
.text( 'Create redirect' )
var $el = $( this );
.on( 'click', function () {
if ( isTreated( $el ) ) { return; }
$( this ).prop( 'disabled', true );
var $span = $el.closest( '.qs-target' );
approve( $cell );
var $label = $span.find( '.qs-target-label' );
} )
var current = $label.text();
.appendTo( $cell );
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 stored value across every segment that uses it.
/* -----------------------------------------------------------------
//
* Rewriting the QuoteSource value on the segments
// 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
function rewriteValue( $el ) {
// keep. It edits every segment carrying that value, so it asks for
var $tr = $el.closest( 'tr' );
// confirmation with the count and reports progress as it goes.
var from = $tr.find( '.qs-value' ).text();
//
var $status = $tr.find( '.qs-rewrite-status' );
// 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(
var to = window.prompt(
'Rewrite this QuoteSource value on every segment that uses it.\n\n' +
'Rewrite this QuoteSource value on every segment that uses it:', from
'This edits the segments themselves, not just a redirect.\n\n' +
'Current value:',
from
);
);
if ( to === null ) {
if ( to === null || to === from || !$.trim( to ) ) { return; }
return;
}
to = $.trim( to );
to = $.trim( to );
if ( !to || to === from ) {
return;
}
var $status = $cell.find( '.qs-rewrite-status' );
$status.text( 'finding segments…' );


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


// One at a time, so a failure stops rather than half-finishing a batch.
function editNext( pages, i, from, to, $status ) {
function editNext( pages, i, from, to, $status ) {
if ( i >= pages.length ) {
if ( i >= pages.length ) {
$status.text( '✓ rewrote ' + pages.length );
$status.removeClass( 'qs-busy' ).addClass( 'qs-ok' )
.text( 'rewrote ' + pages.length + ' segments' );
return;
return;
}
}
$status.text( 'editing ' + ( i + 1 ) + ' of ' + pages.length + '…' );
$status.text( 'editing ' + ( i + 1 ) + ' of ' + pages.length + '…' );
 
getApi().get( {
api.get( { action: 'query', prop: 'revisions', rvprop: 'content',
action: 'query', prop: 'revisions', rvprop: 'content',
rvslots: 'main', titles: pages[ i ], format: 'json', formatversion: 2 }
rvslots: 'main', titles: pages[ i ], formatversion: 2
).done( function ( res ) {
} ).done( function ( res ) {
var page = ( ( res.query && res.query.pages ) || [] )[ 0 ];
var pg = res.query.pages[ 0 ];
var text = page && page.revisions && page.revisions[ 0 ].slots.main.content;
if ( !pg || !pg.revisions ) {
if ( !text ) {
editNext( pages, i + 1, from, to, $status );
$status.text( '✗ could not read ' + pages[ i ] );
return;
return;
}
}
// Exact value replacement, leaving any other sources on the line.
var text = pg.revisions[ 0 ].slots.main.content;
if ( text.indexOf( from ) === -1 ) {
var updated = text.split( from ).join( to );
// Already changed, or stored differently — skip rather than guess.
if ( updated === text ) {
editNext( pages, i + 1, from, to, $status );
editNext( pages, i + 1, from, to, $status );
return;
return;
}
}
var updated = text.split( from ).join( to );
getApi().postWithToken( 'csrf', {
api.postWithToken( 'csrf', {
action: 'edit', title: pages[ i ], text: updated,
action: 'edit', title: pages[ i ], text: updated,
summary: 'QuoteSource: ' + from + ' → ' + to
summary: 'QuoteSource: ' + from + ' → ' + to
Line 301: Line 311:
editNext( pages, i + 1, from, to, $status );
editNext( pages, i + 1, from, to, $status );
} ).fail( function ( code ) {
} ).fail( function ( code ) {
$status.text( '✗ ' + pages[ i ] + ': ' + code );
$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
} );
} );
} ).fail( function ( code ) {
} ).fail( function ( code ) {
$status.text( '✗ ' + code );
$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
} );
} );
}
}


$( '.qs-table' ).on( 'click', '.qs-value-edit', function () {
$tables.on( 'click', '.qs-value-edit', function () {
rewriteValue( $( this ).closest( 'tr' ).find( '.qs-approve' ) );
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 );
} )
);
} );
} );


// Editing a target in place: the suggestion is a guess, and the row is
$( '.qs-controls' ).each( function () {
// useless if the only way to correct it is to edit 67KB of wikitext.
var $box = $( this );
$( '.qs-table' ).on( 'click', '.qs-target-edit', function () {
var $cb = $( '<input>' ).attr( 'type', 'checkbox' );
var $link = $( this );
var $table = $box.nextAll( '.qs-table' ).first();
var $cell = $link.closest( 'td' );
$cb.on( 'change', function () {
var $approve = $cell.closest( 'tr' ).find( '.qs-approve' );
$table.toggleClass( 'qs-hiding', this.checked );
var current = $approve.data( 'to' ) || '';
} );
var value = window.prompt(
$box.append(
'Redirect target for:\n\n' + ( $approve.data( 'from' ) || '' ),
$( '<label>' ).addClass( 'qs-hide-label' ).append( $cb, ' Hide treated rows' ),
current
$( '<span>' ).addClass( 'qs-treated-count' )
);
);
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 );
} );
} );
$( 'tr.qs-treated' ).each( function () { setTreated( $( this ) ); } );
countTreated();
} );
} );
}() );
}() );

Revision as of 13:24, 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;

	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 );
		}

		/* 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;
				var needle = '<code class="qs-value">' + value + '</code>';
				var at = text.indexOf( needle );
				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 = $tr.find( '.qs-value' ).text();
			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 = $tr.find( '.qs-value' ).text();
			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 = $tr.find( '.qs-value' ).text();
			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();

	} );

}() );