MediaWiki:Gadget-QuoteSourceRedirects.js: Difference between revisions

From Bodhicitta
MediaWiki:Gadget-QuoteSourceRedirects.js
(VSCode edit)
(Four actions: rewrite, redirect, create, delete)
 
Line 2: Line 2:
  * QuoteSourceRedirects
  * QuoteSourceRedirects
  *
  *
  * Controls for [[BCA:QuoteSource redirects to create]].
  * Tools for [[QuoteSource Unresolved Values]].
  *
  *
  * - approve  creates a redirect from the QuoteSource value to the target
  * QuoteSource is a page property naming the work a quote segment cites. Where a
*  - edit      changes the target before approving
  * value does not resolve to a text page, one of four things fixes it:
*  - 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
  *   rewrite  change the value on every segment that uses it
  * <label>/<input>/<button> do not survive the wikitext sanitizer.
*  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 () {
( function () {
Line 18: Line 20:
var api;
var api;


/* Mirrors attr() in the page generator — the wikitext stores the value in
function getApi() {
* this form, so a lookup has to escape it the same way. */
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 ) {
function attrEscape( s ) {
return String( s )
return String( s )
Line 28: Line 35:
.replace( /\r\n?|\n/g, '&#10;' )
.replace( /\r\n?|\n/g, '&#10;' )
.replace( /\|/g, '&#124;' );
.replace( /\|/g, '&#124;' );
}
function getApi() {
if ( !api ) { api = new mw.Api(); }
return api;
}
}


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


function setState( $cell, cls, text ) {
/* 27 values contain a newline, which would end the table cell. They are
$cell.removeClass( 'qs-ok qs-err qs-busy' ).addClass( cls ).text( text );
* 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' );
}
}


/* 27 values contain a newline, which would end the table cell. They are
function setStatus( $tr, cls, text ) {
* rendered with a visible pilcrow and carry the real value in
$tr.find( '.qs-status' ).removeClass( 'qs-ok qs-err qs-busy' )
* data-value, so every lookup reads that rather than the cell text. */
.addClass( cls ).text( 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
function isTreated( $tr ) {
* delegated listener cannot fire on a row that has been marked done. */
return $tr.hasClass( 'qs-treated' );
function isTreated( $el ) {
return $el.closest( 'tr' ).hasClass( 'qs-treated' );
}
}


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


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


function countTreated() {
function countTreated() {
var n = $( 'tr.qs-treated' ).length;
var n = $( 'tr.qs-treated' ).length;
$( '.qs-treated-count' ).text(
var total = $( '.qs-table tr' ).has( 'td' ).length;
n ? n + ' of ' + $( '.qs-table tr' ).has( 'td' ).length + ' marked treated' : ''
$( '.qs-treated-count' ).text( n ? n + ' of ' + total + ' handled' : '' );
);
}
 
/* -----------------------------------------------------------------
* 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
* Marking a row handled, persisted into this page's wikitext.
*
* The row's `|-` becomes `|- class="qs-treated"`, found by the
* The row is marked by editing this page's own wikitext: the `|-` that
* data-value attribute, which is unique per row.
* opens the row becomes `|- class="qs-treated"`. The row is found by its
* --------------------------------------------------------------- */
* QuoteSource value, which is unique per row.
* ----------------------------------------------------------------- */
function markInWikitext( value, add ) {
function markInWikitext( value, add ) {
var page = mw.config.get( 'wgPageName' );
var page = mw.config.get( 'wgPageName' );
Line 126: Line 86:
rvslots: 'main', titles: page, formatversion: 2
rvslots: 'main', titles: page, formatversion: 2
} ).then( function ( res ) {
} ).then( function ( res ) {
var pg = res.query.pages[ 0 ];
var text = res.query.pages[ 0 ].revisions[ 0 ].slots.main.content;
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 ) + '"' );
var at = text.indexOf( 'data-value="' + attrEscape( value ) + '"' );
if ( at === -1 ) {
if ( at === -1 ) {
Line 140: Line 97:
? ( line.indexOf( 'qs-treated' ) === -1 ? '|- class="qs-treated"' : line )
? ( line.indexOf( 'qs-treated' ) === -1 ? '|- class="qs-treated"' : line )
: '|-';
: '|-';
var newText = text.slice( 0, rowStart ) + updated + text.slice( lineEnd );
return getApi().postWithToken( 'csrf', {
return getApi().postWithToken( 'csrf', {
action: 'edit', title: page, text: newText,
action: 'edit', title: page,
summary: ( add ? 'mark' : 'unmark' ) + ' QuoteSource row treated: ' + value
text: text.slice( 0, rowStart ) + updated + text.slice( lineEnd ),
summary: ( add ? 'mark' : 'unmark' ) + ' QuoteSource row handled: ' + value
} );
} );
} );
} );
}
}


$tables.on( 'click', '.qs-dismiss', function () {
/* ---------------------------------------------------------------
var $tr = $( this ).closest( 'tr' );
* Finding the segments that use a value
var v = valueOf( $tr );
* --------------------------------------------------------------- */
var $status = $tr.find( '.qs-rewrite-status' );
function segmentsUsing( value ) {
var add = !$tr.hasClass( 'qs-treated' );
return getApi().get( {
 
action: 'ask',
if ( add ) { setTreated( $tr ); } else { clearTreated( $tr ); }
query: '[[QuoteSource::' + value + ']]|limit=500',
countTreated();
formatversion: 2
 
} ).then( function ( res ) {
markInWikitext( v, add ).fail( function ( why ) {
return Object.keys( ( res.query && res.query.results ) || {} );
if ( add ) { clearTreated( $tr ); } else { setTreated( $tr ); }
countTreated();
$status.removeClass( 'qs-busy qs-ok' ).addClass( 'qs-err' )
.text( 'could not save: ' + why );
} );
} );
} );
}


/* -----------------------------------------------------------------
/* QuoteSource can hold several sources separated by ';', so a value is
* Removing a value entirely
* changed by exact match on one part, never by replacing the field. */
*
function editField( text, from, to ) {
* QuoteSource can hold several sources separated by ';', so the value is
return text.replace( /(\|\s*QuoteSource\s*=)([^\n]*)/g, function ( all, head, val ) {
* removed by exact match and the separators tidied, never by blanking
var parts = val.split( ';' );
* the field.
var kept = [];
* ----------------------------------------------------------------- */
for ( var i = 0; i < parts.length; i++ ) {
function stripValue( field, value ) {
if ( $.trim( parts[ i ] ) === $.trim( from ) ) {
var parts = field.split( ';' );
if ( to !== null ) { kept.push( to ); }
var kept = [];
} else {
var i, p;
kept.push( parts[ i ] );
for ( i = 0; i < parts.length; i++ ) {
}
p = parts[ i ];
if ( $.trim( p ) !== $.trim( value ) ) {
kept.push( p );
}
}
}
return head + kept.join( ';' );
return kept.join( ';' );
} );
}
}


function removeOnPage( pages, i, value, $status, done ) {
function editSegments( pages, i, from, to, $tr, done ) {
if ( i >= pages.length ) {
if ( i >= pages.length ) {
$status.removeClass( 'qs-busy' ).addClass( 'qs-ok' )
setStatus( $tr, 'qs-ok', ( to === null ? 'removed from ' : 'rewrote ' ) +
.text( 'removed from ' + pages.length + ' segments' );
pages.length + ' segments' );
done();
done();
return;
return;
}
}
$status.text( 'editing ' + ( i + 1 ) + ' of ' + pages.length + '…' );
setStatus( $tr, 'qs-busy', 'editing ' + ( i + 1 ) + ' of ' + pages.length + '…' );
getApi().get( {
getApi().get( {
action: 'query', prop: 'revisions', rvprop: 'content',
action: 'query', prop: 'revisions', rvprop: 'content',
Line 199: Line 149:
var pg = res.query.pages[ 0 ];
var pg = res.query.pages[ 0 ];
if ( !pg || !pg.revisions ) {
if ( !pg || !pg.revisions ) {
removeOnPage( pages, i + 1, value, $status, done );
editSegments( pages, i + 1, from, to, $tr, done ); return;
return;
}
}
var text = pg.revisions[ 0 ].slots.main.content;
var text = pg.revisions[ 0 ].slots.main.content;
var updated = text.replace(
var updated = editField( text, from, to );
/(\|\s*QuoteSource\s*=)([^\n|]*)/g,
function ( all, head, val ) {
return head + stripValue( val, value );
}
);
if ( updated === text ) {
if ( updated === text ) {
removeOnPage( pages, i + 1, value, $status, done );
editSegments( pages, i + 1, from, to, $tr, done ); return;
return;
}
}
getApi().postWithToken( 'csrf', {
getApi().postWithToken( 'csrf', {
action: 'edit', title: pages[ i ], text: updated,
action: 'edit', title: pages[ i ], text: updated,
summary: 'QuoteSource: remove ' + value
summary: to === null
? ( 'QuoteSource: remove ' + from )
: ( 'QuoteSource: ' + from + ' → ' + to )
} ).done( function () {
} ).done( function () {
removeOnPage( pages, i + 1, value, $status, done );
editSegments( pages, i + 1, from, to, $tr, done );
} ).fail( function ( code ) {
} ).fail( function ( code ) {
$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
setStatus( $tr, 'qs-err', code );
} );
} );
} ).fail( function ( code ) {
} ).fail( function ( code ) {
$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
setStatus( $tr, 'qs-err', code );
} );
} );
}
}


$tables.on( 'click', '.qs-value-delete', function () {
function runOnSegments( $tr, from, to ) {
var $el = $( this );
setStatus( $tr, 'qs-busy', 'finding segments…' );
if ( isTreated( $el ) ) { return; }
segmentsUsing( from ).done( function ( pages ) {
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 ) {
if ( !pages.length ) {
$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( 'no segments found' );
setStatus( $tr, 'qs-err', 'no segments found' ); return;
return;
}
}
removeOnPage( pages, 0, value, $status, function () {
editSegments( pages, 0, from, to, $tr, function () {
setTreated( $tr );
setTreated( $tr );
countTreated();
countTreated();
markInWikitext( value, true );
markInWikitext( from, true );
} );
} );
} ).fail( function ( code ) {
} ).fail( function ( code ) {
$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( 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 ) {
* Editing the target
var value = valueOf( $tr );
* ----------------------------------------------------------------- */
if ( !window.confirm(
$tables.on( 'click', '.qs-target-edit', function () {
'Remove "' + value + '" from every segment that uses it?\n\n' +
var $el = $( this );
'Other sources on the same segment are kept.' ) ) { return; }
if ( isTreated( $el ) ) { return; }
runOnSegments( $tr, value, null );
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 ) );
} );


/* -----------------------------------------------------------------
function doRedirect( $tr ) {
* Rewriting the QuoteSource value on the segments
* ----------------------------------------------------------------- */
function rewriteValue( $el ) {
var $tr = $el.closest( 'tr' );
var from = valueOf( $tr );
var from = valueOf( $tr );
var $status = $tr.find( '.qs-rewrite-status' );
var suggest = $tr.find( '.qs-actions' ).attr( 'data-suggest' ) || '';
var to = window.prompt(
var to = window.prompt( 'Redirect "' + from + '" to which page?', suggest );
'Rewrite this QuoteSource value on every segment that uses it:', from
if ( to === null || !$.trim( to ) ) { return; }
);
if ( to === null || to === from || !$.trim( to ) ) { return; }
to = $.trim( to );
to = $.trim( to );
 
setStatus( $tr, 'qs-busy', 'creating redirect…' );
$status.removeClass( 'qs-ok qs-err' ).addClass( 'qs-busy' ).text( 'finding segments…' );
getApi().postWithToken( 'csrf', {
getApi().get( {
action: 'edit', title: from,
action: 'ask',
text: '#REDIRECT [[' + to + ']]',
query: '[[QuoteSource::' + from + ']]|limit=500',
summary: 'QuoteSource redirect to [[' + to + ']]',
formatversion: 2
createonly: 1
} ).done( function ( res ) {
} ).done( function () {
var results = ( res.query && res.query.results ) || {};
setStatus( $tr, 'qs-ok', 'redirect created' );
var pages = Object.keys( results );
setTreated( $tr ); countTreated(); markInWikitext( from, true );
if ( !pages.length ) {
} ).fail( function ( code, res ) {
$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( 'no segments found' );
if ( code === 'articleexists' ) {
return;
setStatus( $tr, 'qs-ok', 'already exists' );
} else {
setStatus( $tr, 'qs-err',
( res && res.error && res.error.info ) || code );
}
}
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 ) {
/* The page is created with {{LibraryItem}} and the DRL page name, which
if ( i >= pages.length ) {
* is what pulls the work's data across. Nothing else is guessed here. */
$status.removeClass( 'qs-busy' ).addClass( 'qs-ok' )
function doCreate( $tr ) {
.text( 'rewrote ' + pages.length + ' segments' );
var value = valueOf( $tr );
return;
var drl = $tr.find( '.qs-actions' ).attr( 'data-drl' );
}
if ( !drl ) { setStatus( $tr, 'qs-err', 'no DRL page recorded' ); return; }
$status.text( 'editing ' + ( i + 1 ) + ' of ' + pages.length + '…' );
var title = window.prompt(
getApi().get( {
'Create the text page here, with {{LibraryItem}}.\n\n' +
action: 'query', prop: 'revisions', rvprop: 'content',
'DRL holds this work as: ' + drl + '\n\nPage name:',
rvslots: 'main', titles: pages[ i ], formatversion: 2
'Texts/' + drl );
} ).done( function ( res ) {
if ( title === null || !$.trim( title ) ) { return; }
var pg = res.query.pages[ 0 ];
title = $.trim( title );
if ( !pg || !pg.revisions ) {
setStatus( $tr, 'qs-busy', 'creating page…' );
editNext( pages, i + 1, from, to, $status );
getApi().postWithToken( 'csrf', {
return;
action: 'edit', title: title,
}
text: '{{LibraryItem}}',
var text = pg.revisions[ 0 ].slots.main.content;
summary: 'Create text page for QuoteSource value "' + value + '"',
var updated = text.split( from ).join( to );
createonly: 1
if ( updated === text ) {
} ).done( function () {
editNext( pages, i + 1, from, to, $status );
// 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;
return;
}
}
getApi().postWithToken( 'csrf', {
getApi().postWithToken( 'csrf', {
action: 'edit', title: pages[ i ], text: updated,
action: 'edit', title: value,
summary: 'QuoteSource: ' + from + ' → ' + to
text: '#REDIRECT [[' + title + ']]',
} ).done( function () {
summary: 'QuoteSource redirect to [[' + title + ']]',
editNext( pages, i + 1, from, to, $status );
createonly: 1
} ).fail( function ( code ) {
} ).always( function () {
$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
setStatus( $tr, 'qs-ok', 'page created, value redirected' );
setTreated( $tr ); countTreated(); markInWikitext( value, true );
} );
} );
} ).fail( function ( code ) {
} ).fail( function ( code, res ) {
$status.removeClass( 'qs-busy' ).addClass( 'qs-err' ).text( code );
if ( code === 'articleexists' ) {
setStatus( $tr, 'qs-err', 'that page already exists' );
} else {
setStatus( $tr, 'qs-err',
( res && res.error && res.error.info ) || code );
}
} );
} );
}
}


$tables.on( 'click', '.qs-value-edit', function () {
var ACTIONS = [
var $el = $( this );
{ key: 'rewrite', label: 'rewrite', run: doRewrite },
if ( isTreated( $el ) ) { return; }
{ key: 'redirect', label: 'redirect', run: doRedirect },
rewriteValue( $el );
{ key: 'create',  label: 'create',  run: doCreate },
} );
{ key: 'delete',  label: 'delete',  run: doDelete }
];


/* -----------------------------------------------------------------
/* ---------------------------------------------------------------
* Build the controls
* Build the controls
* ----------------------------------------------------------------- */
* --------------------------------------------------------------- */
$( '.qs-approve' ).each( function () {
$( '.qs-actions' ).each( function () {
var $cell = $( this );
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(
$cell.append(
$( '<button>' ).addClass( 'qs-btn' ).text( 'approve' )
$( '<button>' ).addClass( 'qs-dismiss' ).text( 'done' )
.attr( 'title', 'Mark this row handled' )
.on( 'click', function () {
.on( 'click', function () {
if ( isTreated( $cell ) ) { return; }
var value = valueOf( $tr );
approve( $cell );
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' )
);
);
} );
} );
Line 362: Line 328:
$( '.qs-controls' ).each( function () {
$( '.qs-controls' ).each( function () {
var $box = $( this );
var $box = $( this );
var $cb = $( '<input>' ).attr( 'type', 'checkbox' );
var $table = $box.nextAll( '.qs-table' ).first();
var $table = $box.nextAll( '.qs-table' ).first();
$cb.on( 'change', function () {
if ( !$table.length ) { $table = $( '.qs-table' ).first(); }
$table.toggleClass( 'qs-hiding', this.checked );
var $cb = $( '<input>' ).attr( 'type', 'checkbox' )
} );
.on( 'change', function () {
$table.toggleClass( 'qs-hiding', this.checked );
} );
$box.append(
$box.append(
$( '<label>' ).addClass( 'qs-hide-label' ).append( $cb, ' Hide treated rows' ),
$( '<label>' ).addClass( 'qs-hide-label' ).append( $cb, ' Hide handled rows' ),
$( '<span>' ).addClass( 'qs-treated-count' )
$( '<span>' ).addClass( 'qs-treated-count' )
);
);
Line 375: Line 342:
$( 'tr.qs-treated' ).each( function () { setTreated( $( this ) ); } );
$( 'tr.qs-treated' ).each( function () { setTreated( $( this ) ); } );
countTreated();
countTreated();
} );
} );


}() );
}() );

Latest revision as of 16:47, 17 September 2026

/**
 * 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, '&amp;' )
			.replace( /"/g, '&quot;' )
			.replace( /</g, '&lt;' )
			.replace( />/g, '&gt;' )
			.replace( /\r\n?|\n/g, '&#10;' )
			.replace( /\|/g, '&#124;' );
	}

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

}() );