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, '<' ) | |||
.replace( />/g, '>' ) | |||
.replace( /\r\n?|\n/g, ' ' ) | |||
.replace( /\|/g, '|' ); | |||
} | |||
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 | /* 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 ) { | 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 | 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 | 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 | 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, '&' )
.replace( /"/g, '"' )
.replace( /</g, '<' )
.replace( />/g, '>' )
.replace( /\r\n?|\n/g, ' ' )
.replace( /\|/g, '|' );
}
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();
} );
}() );