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